Shockingly, code that you or someone else wrote has been deemed imperfect and you are now responsible for fixing it. Your first instinct may be to panic. You may have a testy customer or an impatient superior who has made sure to impress upon you the importance of your swiftness. You may just not like fixing bugs. In any case, don’t be so hasty. Rushing a bug fix is a good way to create several more.
Bug fixing is a true test of your problem-solving skills. Bugs are not typically random or due to chance. They each have a logical explanation. If you approach the bug fixing process in an organized and logical manner, you will be able to find solutions in a more reasonable and predictable amount of time.
Reproducing the defect is the most important part of the process. If you can’t reproduce it consistently, the rest of the process will be much more difficult.
Reproduction generally involves a particular state of the system and sequence of actions leading up to the undesired behavior. As simple as this may sound, there are many obfuscating factors that make determining these things more difficult, including abstraction (things that happen that the user doesn’t know about), environment-specific issues, race conditions, and missing details in bug reports. So while some behavior may seem intermittent, the underlying code works in a very defined and predictable way. It is up to you to determine the factors that eliminate this intermittence.
Steps of reproduction should be as concise as possible. Meaning, you should try to eliminate any steps that have no affect on the resulting behavior. Doing this will help narrow down the possible problem areas in your code and will speed up testing. Look to eliminate steps that cancel each other out or are due to coincidence. For example, do I really need to be logged in as Test User 3 to cause the application to crash or will any user suffice?
Isolate the Issue
Now that you hopefully have a consistent, concise set of steps to reproduce, it’s time to isolate the issue. This is the part that actually involves narrowing down the possible problem area(s) in your application (including any third-party software you may be using) to the exact line or lines of code that need fixing.
Look in the most obvious places first
“If you see hoof prints, think horses – not zebras.”
– The Pragmatic Programmer
Don’t skip any steps. Do your due diligence and eliminate possible problem areas of your application, starting with the most obvious ones relating to the nature of the issue you are having. Most issues you encounter will be an issue with your own project’s code, not third-party software. Don’t jump to any conclusions until you have verified them. You may think this is a waste of time, but you may lose a lot more time by working in the opposite direction; only to discover that the solution was the most obvious one.
Don’t mistake symptoms for the root issue
Symptoms are behaviors that are caused by the root issue. Root issues can generally be traced directly to an explicit action or inaction of a developer. If you can’t define what that is, you may be looking at a symptom. Covering up symptoms does not solve the problem; it merely hides its effects. If you don’t fix the root issue, it could rear its ugly head again, and your previous cover-ups will only serve to further obfuscate the issue and prolong the process.
I encounter symptoms a lot in front-end web development, especially when using third-party libraries that update the DOM behind the scenes. Generally, debugging formatting and display issues starts with inspecting the DOM in the developer console. Frequently, I trace a particular issue down to the presence of a single CSS class on a node. I could naively remove the class programmatically when the page or node is loaded, or perhaps I could override the class styling or event-based behavior for this particular page, but I would be ignoring the root issue. How did the class get there? Was it added somewhere in our code? Did a library add it? If a library added it, why? Is something misconfigured? Asking and answering these questions is key to uncovering the root issue.
Uncovering the root issue involves understanding and being able to explain exactly why a behavior occurs. If you cannot do this, dig deeper.
Use some of these common methods
As a software developer, your Googling skills should be one of the sharpest tools in your shed. You may find that others have had the same issue as you, or that there is a known bug in some third-party software you are using, which would allow you to skip several steps of the bug fixing process and save yourself hours. If you don’t know where to start when attempting to isolate the issue, this should be your first step. If you aren’t yet able to express the issue you are having in searchable terms, you may need to come back to this step once you have a somewhat better understanding of the problem.
Read the docs
If you are using any libraries or frameworks (third-party or even internal), I would always recommend to read the documentation for the one(s) relevant to your issue early on. This is so you can verify that you are using them properly, and perhaps to discover any relevant common pitfalls. When learning how to use tools like these, we often learn just enough to do what we need to do. When reading documentation, I often find that I’m using something incorrectly or using the wrong function, etc. At the very worst, you will gain more of an understanding of the tools you’re working with, which will only have positive benefits going forward.
Use your debugger
If you take nothing else away from this post, remember this. Learn to use your language’s debugger. I’m frequently surprised by how many developers use print statements (which we’ll talk about in a moment) exclusively. Print statements have their (specific) uses, but not knowing how to use a debugger or chalking it up to preference are not good enough excuses. Debuggers allow you to interactively explore an instance of your application at a specific point in its execution. This is extremely powerful and can trim a lot of time off of the bug fixing process, not to mention that you only need to know three or four commands to be effective for the vast majority of cases.
Here are just a few reasons I prefer using a debugger over print statements for most cases:
- It is much cleaner and easier to determine if a particular block of code was reached. Simply set a breakpoint. If it gets hit, it was reached.
- It isn’t necessary to take the time to write something meaningful in each print statement, so that you know where it came from later. You are always able to know exactly where you are in the code when using the debugger.
- It provides the ability to inspect the state and scope of the application at each breakpoint. It would be extremely difficult and messy to try to recreate this with print statements.
- At each breakpoint, you can execute code in the scope at that moment. This is great for debugging as well as testing several ideas at once without having to rebuild and test the application manually each time.
Note that a debugger isn’t the only tool you can use to debug. Become familiar and use all of the tools at your disposal. For example, a web developer you be familiar with each browser’s developer console.
Use print statements
I only recommend using print statements when using the debugger would either be too tedious (e.g. quickly seeing the state for each iteration of a long loop) or not possible (e.g. race conditions or event-based behavior). For these instances, print statements are essential. Otherwise, use your debugger.
I most frequently have to use print statements when using jQuery. Since jQuery is event-based, setting breakpoints often disrupts the natural flow of these events. For example, when investigating an issue that happens on the
focus event on a particular node, hitting a breakpoint changes the focus to the developer console. Behavior like this is very difficult to debug with a debugger. It is best to use print statements in these cases.
Use source control
Hopefully by now you are using source control for all of your projects. If so, it can be very helpful to find the commit that introduced the undesired behavior. You can do this by checking out commits one at a time and running through the reproduction steps. Once you find a commit that works, you can probably conclude that the one that followed it introduced the bug. This is a great reason to keep your commits relatively small.
Use the rubber duck method
You have probably heard of this one, but it bears repeating. Rubber duck debugging involves explaining your code (out loud or otherwise) to a person or an inanimate object (e.g. a rubber duck). It is effective because it forces you to put your thoughts into words and helps uncover disconnects between your understanding of the code and how it actually works. It also helps define your understanding of the code explicitly, so that even if you don’t immediately find an inconsistency in your logic, you will be able to more easily spot them later on.
Additionally, if you can bother an actual person to look at your code and hear out your issue, they will often be able to catch things that you didn’t. Just make sure to do your due diligence in attempting to resolve the bug on your own before asking for help. Most people want to help, but they want to see that you’ve put in some effort first.
Look at similar functionality
Are you developing functionality that closely matches other functionality in your own system or elsewhere? If so, it may be helpful to compare your code with the code that works. You may be missing something. If you find yourself in this situation a lot, you may need to look into generalizing your code so that the common functionality exists in only one place.
Comment out code
Are you trying to fix something that used to work? Maybe some new functionality was added recently and things haven’t quite worked the same since. In this case, I would recommend that you remove as much unnecessary code as possible; keeping only the code that is necessary for the steps to reproduce.
Here are the basic steps, starting out with large blocks of code and breaking them down into smaller chunks as you go along:
- Comment out a block of code.
- Can you still follow the steps to reproduce?
- If yes, continue.
- If not, break the block into smaller chunks and repeat these steps for each of them.
- Is the undesired behavior still present?
- If yes, this block of code can be eliminated from consideration. Move on to the next block.
- If no, you’ve found the problem area! If this block has more than one line, you may need to break it down further to find the specific line(s) that cause the issue.
- Can you still follow the steps to reproduce?
Your understanding of the codebase can make this process much quicker. The more you understand, the easier it will be for you to determine which parts of the code are necessary and which ones are not, which will speed up this process a lot.
Read and debug third-party source code
If you’re working with third-party software, and the relevant documentation is sparse or you’re having an issue that can’t be found on any forums, don’t be afraid to dig into the source code (if you have access to it). You can learn a lot by reading the source code, and even more by walking the debugger through it. You may discover a bug in their code, or more likely, you’ll realize how you’re using it incorrectly. Sometimes this is the quickest way to finding your issue.
Ask for help
I list this last, not because it should be your absolute last resort, but because you should be fairly (but not overly) exhaustive in trying to determine your issue before asking for someone else’s time. Even if you know that someone else would be able to give you an answer right away, being exhaustive will improve your debugging skills and make you an overall better developer. That said, also don’t be afraid of asking for help either. Most people, in person or in online forums, want to help you, but they are more willing to do so if they see that you’ve done your due diligence.
Resolve the Issue
Congrats, you’ve isolated the root issue and are ready to fix it and get on with your life. Now is not the time to be hasty. There are several things you should consider before and during your modifications to the code.
Write failing unit tests
Before you even start attempting to fix the bug, be sure to write some failing unit tests for the problem code if you haven’t already. Make sure your tests are thorough, such that when they succeed, you can be sure that the bug has been fixed and behavior is as you expect. This is important to do before updating any code because it forces you to understand and think about your code, as well as giving you a clear way to measure success.
Think about the consequences
When updating the code, always think about the consequences. Be especially careful making updates to code that is widely shared. You’ll do more damage than good if you mess it up. Think about other parts of your application that may suffer from the same issue. This is a good opportunity to fix the issue across the board; not just this one specific case.
Leave the code better than you found it
If the problem code is in rough shape and in need of refactoring, this could be a good opportunity to do so. If this is a common problem area in your application, you could save yourself and/or your team a lot of trouble in the future by improving the code now. Of course, do so in moderation. It is all too easy to get carried away refactoring.
Don’t break anything
Hopefully, your project employs continuous integration, but if it doesn’t, make sure you didn’t break any automated tests. Even if you have these tests in place, it is generally a good idea to do some manual regression testing, especially if the code you updated affects a lot of functionality.
The best way to fix bugs is to prevent them and to make them easier to find. I know, this doesn’t help you right now, but it will help you and your team in the long run. People are imperfect, and so we will never completely eliminate bugs, but we can eliminate a lot of them, and we can make them easier to track down when they do occur.
Perhaps this goes without saying, but learning and knowing your codebase and the technologies it uses better is one of the best ways to prevent bugs. Some of this comes with experience, but being proactive is a catalyst. Take some time to read documentation, take on tasks for parts of your system that you are unfamiliar with, and continue to push yourself outside of your comfort zone in general.
Write good code
Again, this one is obvious, but it is a good reminder. Read up on best coding practices in general and for your language/technology and employ them. Practice modularity, keep your code DRY, and write code that you would want to maintain.
Maintain thorough automated tests
A project without automated testing is sure to be constantly broken. If you have more than one person on your team, consider employing continuous integration to really enforce this.
Commit early, commit often
As discussed previously, source control is your friend when it comes to tracking down the introduction of bugs into your system. If you force yourself to make small commits often, you will also be forcing yourself to break your problems down into smaller parts, which is a great practice. The less code you introduce into the system at a time, the easier it will be to pinpoint which code is responsible for breaking things.
Also note that this doesn’t just apply to the commits you push to the repository. You can also employ this locally with even smaller commits that you can later squash into a bigger one. This allows you to revert to an earlier version of your work in progress if you run into an issue.
There are some instances when you may not have access to the environment where the bug was discovered (e.g. production and user reports). It is a good idea to use detailed logging so that you can determine what happened as accurately as possible.