Debuggability is an attribute of your code base, while debug ability is a trait of your coders. I reason that keeping both high is at the core of being a good software engineer.
Story time – back when I was little
Back when I was still a junior developer, I got assigned a bug in a big, distributed app. It was running within a Java application server and even reproducing the issue was a taunting task. After starting to recede my hairline by pulling them out, I went to the senior lead dev. The first thing he did was to start with a new program.
He pulled the dependencies in, used the application server context and executed precisely only the code which triggered the issue, reproduceable with a click on the green play-button. He then proceeded to go into the bug inspecting where it might go wrong.
„Wow, OK, I see why you ran into trouble understanding the issue.“, he said, while scrolling through he 500 lines method. With further help, I eventually managed to write a unit-test and refactor the method a tiny bit.
But what skills exactly did the senior use to get me over this plateau of desperation and incompetence in only five minutes?
Clear understanding of the code base in combination with a strong mental model of what is supposed to do what. (His personal debug-ability)
Skills and understanding of the problem area to create a Minimal, Reproducible Example. → Increasing the debuggability of the code.
Debuggability
Back when I started to code in the early 2000s, it was still not common to use unit tests. Neither examples, nor tutorials or even books contained chapters about testing. But these are sins of the past.
Today, testing is a fundamental part of development, and I can’t imagine a CI/CD pipeline without making sure everything’s green. Beside ruling out old bugs are re-introduced; the tests should also inform developers of what the code is supposed to do – by explaining and showing.
Unit tests are the cheapest and fastest way to get a MRE going. Guiding your coding with a test does also wondrous things to its API and usability. It adds a very important requirement: make it work with as little hassle as possible. If possible, don’t force mocks, DB connections, etc. - keep it as simple as possible while keeping the code useful.
I found one example of such a concise MRE in the react code base:
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
'use strict';
const {evalStringConcat} = require('../evalToString');
const parser = require('@babel/parser');
const parse = source => parser.parse(`(${source});`).program.body[0].expression; // quick way to get an exp node
const parseAndEval = source => evalStringConcat(parse(source));
describe('evalToString', () => {
it('should support StringLiteral', () => {
expect(parseAndEval(`'foobar'`)).toBe('foobar');
expect(parseAndEval(`'yowassup'`)).toBe('yowassup');
});
it('should support string concat (`+`)', () => {
expect(parseAndEval(`'foo ' + 'bar'`)).toBe('foo bar');
});
it('should throw when it finds other types', () => {
expect(() => parseAndEval(`'foo ' + true`)).toThrowError(
/Unsupported type/
);
expect(() => parseAndEval(`'foo ' + 3`)).toThrowError(/Unsupported type/);
expect(() => parseAndEval(`'foo ' + null`)).toThrowError(
/Unsupported type/
);
expect(() => parseAndEval(`'foo ' + undefined`)).toThrowError(
/Unsupported type/
);
});
});
A nice way of putting it, is that you should make your tests FIRSTR (based upon F.I.R.S.T):
Fast
Independent (tests shouldn’t rely on each other → can you change the order?)
Repeatable (re-running tests should not change the outcome)
Self-Validating (running tests should tell you if they pass or not)
Timely (write tests while working on the code, or even before)
READABLE (most importantly)
If you don’t do that, unit tests tend to degenerate to a huge mess. Uncle Bob shows an example of such a not so easy to read test in his classic Clean Code.
First he wents over a unit test of the fitnesse project. He then proceeds to move functionality to methods and make each test more readable.
// original source code
public void testGetPageHieratchyAsXml() throws Exception {
crawler.addPage(root, PathParser.parse("PageOne"));
crawler.addPage(root, PathParser.parse("PageOne.ChildOne"));
crawler.addPage(root, PathParser.parse("PageTwo"));
request.setResource("root");
request.addInput("type", "pages");
Responder responder = new SerializedPageResponder();
SimpleResponse response = (SimpleResponse) responder.makeResponse(new FitNesseContext(root), request);
String xml = response.getContent();
assertEquals("text/xml", response.getContentType());
assertSubString("<name>PageOne</name>", xml);
assertSubString("<name>PageTwo</name>", xml);
assertSubString("<name>ChildOne</name>", xml);
}
// after refactoring, becomes this:
public void testGetPageHierarchyAsXml() throws Exception {
makePages("PageOne", "PageOne.ChildOne", "PageTwo");
submitRequest("root", "type:pages");
assertResponseIsXML();
assertResponseContains(
"<name>PageOne</name>",
"<name>PageTwo</name>",
"<name>ChildOne</name>"
);
}
Ironically, the refactored unit tests code did never become part of the actual project. (See for yourself: current unit test source)
Debug Ability – individual skills
Back in school, my calculus teacher used to say: deriving is a skill, finding an integral is art. Same goes for debuggability and debug ability: while related, one can be found by sticking to solid engineering principles, the other takes experience and practice.
Debug ability is formed out of these factors:
Strong mental model of the application, used tools, libraries and frameworks
Experience with bugs and behavior of the application in different situations
Experience with reading code
Simply put, the mental model encompasses the understanding of what the application tries to achieve, how it does it and most importantly why it does it. (Never underestimate the power of the why.)
The rest is experience which comes with solved problems. (This can be sped up by pair programming with a seniors who makes sure you do understand what they’re doing – and why.)
Reading code is available to everybody but rarely done. When in doubt utilize the amount of high-quality open-source projects and investigate how they’re doing it. Ever wondered how webpack bundles? React parses a JSX file? Angular doing virtually everything? Have a look in the code.
In a follow up article, I’ll go into details how these concepts apply when trying to introduce more unit tests to a project.