How to Write Testable Code

This post is not about Test Driven Development, or testing best practices, or any advanced concept in testing. Instead, the goal is to recommend a simple mindset change that allows for writing testable code without needing to write any tests first.

The point of this is to eventually write tests, because not every team has the luxury of test driven development. This mindset change will allow writing code that can be easily tested at a later point in time, and that carries some of the benefits of TDD without having to write tests beforehand.

But first, a story...

Will has just joined an online education company as a software engineer. His first task was to add a class average metric to a job that runs after an exam is completed.

The product manager explains the task clearly, and Will digs into the code. After asking a coworker, he figures out where the code for this job lies and finds the following:

def post_exam_computation(exam_id):
    exam_results = db.get(exam_id)  # Database call
    
    max_score = max(exam_results)
    min_score = min(exam_results)
    
    exam_stats = {"max": max_score, "min": min_score}
    db.save(exam_stats)

Seems like an easy enough job, Will modifies the code to add an average statistic:

def post_exam_computation(exam_id):
    exam_results = db.get(exam_id)
    
    max_score = max(exam_results)
    min_score = min(exam_results)
    avg_score = sum(exam_results) / len(exam_results)
    
    exam_stats = {"max": max_score, "min": min_score, "avg": avg_score}
    db.save(exam_stats)

Excited to have finished the task pretty quickly, the only thing left was to make sure that his implementation works without breaking anything. After such a check, he could commit the code with confidence.

Talking to his coworker again helps him understand how to test the platform, so he goes ahead and creates a new class, creates an exam, creates two students, logs in as each and submits the exam. The post exam job runs, and luckily, the results seemed fine.

However, it took Will half a day to setup the end-to-end test and create all that dummy data. It would also take him around an hour to do this again in case someone requested another change to this piece of code. He'd have to try an end-to-end test with multiple input examples to try to cover edge cases.

Productivity Drains

In the above story, Will encounters multiple potential productivity drains that affect his independence as a developer, his coworkers' productivity, and his speed and confidence in committing changes to the code. Having tests would probably solve all of these problems:

  • Test suites act as documentation (no heavy dependence on coworkers)
  • Running tests will make sure the change did not break any previous functionality, and ensure new functionality works as expected
  • Writing tests will make the developer think about edge cases

But you probably don't need any convincing on the usefulness of tests. The above drains would also be encountered if Will was an experienced developer at the company.

Let's talk about Testability

Our definition of testability will be simply how easy it is to test a certain piece of code. Consider the above function:

def post_exam_computation(exam_id): # Requires exam_id

    exam_results = db.get(exam_id) # Requires mocking db
    
    max_score = max(exam_results) # Need exam results
    min_score = min(exam_results)
    
    exam_stats = {"max": max_score, "min": min_score}
    
    db.save(exam_stats) # Requires mocking db

For the change Will introduced, the only part that would need testing is the newly introduced average score (Let's ignore the meaning of a schema change for this db for simplicity). It would have sufficed if Will wrote a test for max_score, min_score, and avg_score to confirm that his code will work.

The issue is with the way the code is organized. It's hard to test with the presence of dependencies on a database (and potentially many other things in production code). A more "testable" code would be the following:

def calculate_stats(exam_results):
    max_score = max(exam_results)
    min_score = min(exam_results)
    
    return {"max": max_score, "min": min_score}

def post_exam_computation(exam_id):
    exam_results = db.get(exam_id)
    exam_stats = calculate_stats(exam_results)
    db.save(exam_stats)

If we wanted to write a test for this code, we could simply test calculate_stats with some dummy data.

The change needed to go from untestable to testable in this example is pretty simple to make, which isn't the case in the real world. This toy example serves to illustrate that just adopting a testable code mindset will result in testable functions, which we could easily write tests for sometime in the future.

If the code Will came across was testable, he would have modified the code, then wrote a test to confirm that it works as expected. There would have been no need for setting up an end-to-end test and wasting time populating frontend dummy data then running the whole job to check if it works for different inputs.

This would probably be different if Will saw complicated dependencies throughout the code. Adding tests to such code requires refactoring large sections, and as a newcomer, that is very hard to do since it requires knowledge of the code. Over time, Will would probably get used to the untestable code style and low levels of productivity.

Example of testable code with the addition of a test:

def calculate_stats(exam_results: List[int]) -> Dict[str, float]:
    max_score = max(exam_results)
    min_score = min(exam_results)
    avg_score = sum(exam_results) / len(exam_results)
    
    return {"max": max_score, "min": min_score, "avg": avg_score}
    
def post_exam_computation(exam_id):
    exam_results = db.get(exam_id)
    exam_stats = calculate_stats(exam_results)
    db.save(exam_stats)
    
def test_calculate_stats():
	exam_results = [1, 2, 3]
    stats = calculate_stats(exam_results)
    
    assert stats["max"] == 3
    assert stats["min"] == 1
    assert stats["avg"] == 2

This shows us how testable code (even if we ignore the test) has achieved the following:

  • Code documentation (stats extraction is in its own function called calculate_stats)
  • Ease of writing tests (since code is properly separated) compels developers to add tests
  • Gives developers confidence to make changes quickly by creating and running tests

The above code would also be the outcome of applying a separation of concerns pattern, but reached differently by simply thinking of code testability. The benefits of such a mindset exceed those of extensive design pattern knowledge. Simpler foundations result in more elegant, developer-friendly code.

In summary, adopting a mindset of writing testable code yields many benefits; free documentation, ease of adding tests in the future, and increasing confidence in committing changes due to simpler code are just some of them. This is the essence of test driven development, without forcing a tests-first approach.