[Example] test-first bug-fixing in JavaScript
Stijn Dejongh
Hands-on software solution architect | freelancer | husband | father | human
“Writing tests slows me down, let me just fix the issue”
This is a sentence I often hear when advising people to start their bug-fixing by writing an automated test that illustrates the problem before changing any production code.
The kicker is: even though this approach might feel slower than just hacking away at the live code base and redeploying it a few times to see if you solved the issue, it usually ends up being faster.
This is especially true in environments where deployments take substantial amounts of time. As an example, let's consider a large enterprise environment with deployment pipelines that take 4-5 minutes to compile, repackage, validate, and complete the installation on multiple mirrored servers.
“Okay, you might have a point. How do I do this?”
Before I continue to explain my bug-tackling process, I first need to warn you: Do not apply this technique in a high-pressure environment without practicing it first. Many developers have made the mistake of trying a new technique or tool on the job without using it in a safe-to-fail environment first. Save yourself the anger of your team, boss, and customers, by trying new practices on your personal projects before using it in the wild.
Now, without further ado, let's dive into the nitty-gritty of resolving a bug by writing a specific test for it. In this article, I will share some code snippets from a very simple open-source project I have been working on for a while.
Code-Along Walkthrough
Project description
Docsify is a Node.js project that allows its users to generate static documentation websites from simple markdown files. It is designed to be easily extensible by installing plugins. Various plugins exist that allow users to mix-and-match functionality. This gets them a documentation site that fits their particular needs.
A while back, I was looking for a way to add a glossary (a fancy name for dictionary) to my personal documentation wiki. I stumbled across a plugin that mostly did what I wanted to achieve, but it soon turned out it did not really fit all of my requirements. So I did what any developer would do, and changed the code base. You can find my spin-off version of the plugin on GitHub.
A wild bug appears
I managed to extend the original plugin to fit my needs, including the option to configure an alternate location for the list of definitions that is used to replace words in all other pages. Unfortunately, I messed this up. The generated links still included the relative filesystem path to my dictionary file, resulting in the site navigation to go haywire.
As shown in the screenshot above, the 'context' link points to:
https://localhost:3000/#/./X_Appendix/Glossary/HOME?id=context
It should point to:
https://localhost:3000/#/X_Appendix/Glossary/HOME?id=context
Hunting down the bug
Can we test it? Yes we can!
Let's start by writing a test that illustrates what we expect the link replacement to do, rather than what it is doing now. If done well, the test will fail until we solve the issue in our code.
it(' can handle relative link configurations', () => {
let glossaryLocation = './alternate/location/glossary/README.md';
config = glossifyConfig()
.withGlossaryLocation(glossaryLocation)
.withLinkPrefix('#')
.build();
dictionary = loadTerminology(sourceText, config);
expect(dictionary['API']).toBeTruthy();
let result = addLinks(
textWithWordsToReplace,
dictionary,
config
);
let expectedLink = '/#/alternate/location/glossary/README\\?id=api';
expect([...result.matchAll(expectedLink)]).toHaveLength(2);
});
The test first sets up a custom glossary definition location, using a relative file path. It loads these word definitions, and checks whether this went well. This little validation step after setting up our experiment is called precondition checking. Precondition checks ensure that our starting point is what we expect it to be. I do this as it makes no sense to evaluate the result if the set-up was wrong to begin with (junk in, junk out).
Next up, we let the code add links to the original text. You might notice that the variables `sourceText` and `textWithWordsToReplace` are not included in this test. The reason is that they are defined earlier in the test specification as a shared field, as you can see in the snippet below.
let dictionarySource;
let textWithWordsToReplace;
beforeEach(() => {
dictionarySource = `
##### API
Application Program Interface. Specifies a set of software functions that are made available to an application
programmer. The API typically includes function names, the parameters that can be passed into each functions, and a
description of the return values one can expect.
`;
textWithWordsToReplace = `
# This is an API title
This is a paragraph of text, explain stuff and mentioning the term API.
`;
});
When we run this test specification, it should fail. Do not try to fix the code before making sure that your test accurately illustrates the problem.
Now, to make sure our test is working correctly, we will quickly change the test to make it expect the incorrect link value (the one containing `./` in its path).
let expectedLink = './alternate/location/glossary/README\\?id=api';
expect([...result.matchAll(expectedLink)]).toHaveLength(2);
If this makes the test run without failing, we are sure that it fails for the correct reason, and not because we made some mistake while writing it. Yes, I am in fact testing my test. Yes, this sounds silly. And yes, failing to do this has cost me countless hours of chasing ghost bugs in the past. Don't repeat my mistakes, you are smarter than that!
领英推荐
Now, let's revert our specification back to what it should be, so our test fails again.
We are now ready to fix the bug! At this point, I usually do an intermediary commit to my git repository. This way, I do not lose my work in case my computer decides to malfunction at an inopportune moment.
Please note that it takes a lot longer to write this down and to read through my description of using the technique than it does to actually do it. Everything described up until now took me less than 5 minutes in real time.
Find and fix the code issue
Using my test to trigger the code execution, I can now make use of my code editor's built in debugger to step through the code and identify where the defect is. For the sake of maintaining some brevity to this article, I will not describe this in this article.
Soon, I found that these lines of code are the culprits.
let compiledLink = config.glossaryLocation.replace('.md', '');
let link = ` [$1](/${compiledLink}?id=${linkId})`;
let replacement = contentLine.replace(reComma, link + ',')
.replace(re, link + ' ')
.replace(reFullStop, link + '.');
Now to fix the problem
“Past me” has neglected to take into account that the location of the glossary could be provided as a relative path. Let's add a sub-string replacement that gets rid of any occurrences of './' in our file path.
Our code now reads:
let compiledLink = config.glossaryLocation
.replace('./', `${config.linkPrefix}/`)
.replace('.md', '');
You might now be thinking about edge cases where this would go wrong. Such as: “What if one of my directories end in a dot (full stop) ?”.
To which I say: “You are absolutely correct, but I can't be bothered to support weird directory naming fetishes. Don't use dots at the end of your directory names if you want to use my plugin.”.
Let's run our test and see if we actually managed to fix the problem!
Looks like we did a good job. Kudos to us!
But wait! Let's first make sure we did not accidentally ruin other functionality by making our changes. Luckily, this can easily be done by running all existing tests.
Now that we know we resolved the issue, we can test it on the live system to be extra sure everything works as intended. In general, when all of your automated tests succeed, you can be fairly sure that you did not break anything. But as we all know: “better to be safe, than sorry”. You do not want to be the person to single-handedly bring down your company's flagship product.
In a lot of real-life scenarios, the code you are working on will not be accompanied by a good set of tests. In that case, it can be tricky to know if your change had an unintended side effect. So be extra careful to check it yourself. Alternatively, there are ways to quickly create your own safety net by writing a few tactical automated tests (either manually, or with techniques such as 'approval testing' or 'snapshot validation').
Cleared for take-off!
We fixed the bug, so let us commit the code change and merge our change into the mainline branch (or create a Pull Request if you are working on a project that uses them).
We managed to do all that in about a quarter of an hour. Not too shabby, right?
Your turn to try!
I hope you enjoyed this write-up of how I tackled a small bug in one of my hobby projects using a test-first approach.
Now get out there, and try doing it yourself!
If you want, you can use the same source code as I did. This way, you can repeat my steps to get a feel for the technique before you try to apply it on one of your own codebases. You can find the full source code used in this article here: github.com/stijn-dejongh/docsify-glossary
If you learned something new or tried your hand at using this technique, please leave a comment below and tell me how it went. I am curious to know your experiences with the test-first bug fixing approach.
Thank you for reading, and as always: happy coding
Acknowledgements and References
Thanks to these wonderful people
Many thanks to Tim S. , Kobe Vanlaere , and Gert Vanautgaerden for proofreading this article and helping me make it more coherent.
This code-along style article was inspired by this post, in which Ebenmelu Ifechukwu talks about the challenges they faced when using test-first approaches.
References
Cloud Engineer at KBC
9 个月Good reminder to let tests explicitly fail and make sure the test is actually testing the bug! During redgreen testing, I also catch myself not letting the test properly fail and immediately changing the implementation to let it pass. I will keep it in mind for next time ??
Employeneur at TMC
1 年"An article about unit testing from a developer? That is strange." A qoute that I got when speaking about this article. Nice to see how you try to fix a system instead making a workaround for a bug.
Hands-on software solution architect | freelancer | husband | father | human
1 年It looks like something went wrong with the initial publishing of the article, part if it became invisible on mobile. This should be resolved now.