Le Cube ◇ Mass Psychosis

The Blog

FMOD
ReaScripts for Cockos REAPER
Test-Focused Development
The What, How, Why and When
TDD-ing Avatar Health in C# and C++
TDD-ing Chess in C#
Lifting

Test-Focused Development

The What, How, Why and When

by Egill Antonsson
published on 2021 Dec 30
updated on 2023 Nov 13

The What and How

Test-Focused Development is a broad term that encompasses a variety of methodologies that prioritize writing unit tests as a significant part of the development process. The main methodology and skill is Test-Driven Development (TDD) that focuses on when to write unit tests, thus there are 2 underlying skills needed to be applied as well to reap the benefits of TDD / Test-Focused Development: Writing Good unit tests and designing Good architecture. Let's go through these 3 skills.

Good unit test

The book The Art of Unit testing, 3rd edition by Roy Osherove focuses on writing good unit tests and I recommend reading it. Below is summarized definitions in the 1st chapter of his book (I rephrased a bit and add some of my own thoughts).

Good automated test (not just unit test)

  • Should be easy to read and understand and to write.
  • Should always return the same result if you don't change anything between test runs.
  • Should be useful and provide actionable results.
  • Anyone should be able to run it at the push of a button.
  • When it fails, it should be easy to detect what was expected and pinpoint the problem.
  • Should be Self validating / it passes or fail without manual interpretation (the 'S' in F.I.R.S.T ).

Good unit test

  • Should run fast and frequently. Slow runs will lead to less frequent runs, that will lead to problems found out late and thus harder to fix, that leads to code rot (the 'F' in F.I.R.S.T).
  • Should have the control of the code under test that is needed to write all meaningful test cases.
  • Should be fully independently / isolated from of other tests (the 'I' in F.I.R.S.T).
  • Should run in memory without requiring system files, networks or databases, and be Repeatable in any environment (the 'R' in F.I.R.S.T)
  • Should be as synchronous and linear as possible, i.e. no parallel threads if possible.

Unit of Work

A Unit of Work is the sum of actions that take place between the invocation of an Entry Point (what is triggered) up until a noticeable end result through one or more Exit Points.

Given a publicly visible function:

  • The function's body is all or part of the Unit of Work.
  • The function's declaration and signature are the Entry Point into the body.
  • The resulting outputs or behaviors of the function are its Exit Points.
Unit of Work with all 3 exit point types
Unit of Work with all 3 exit point types

The 3 types of Exit Points

A good unit test should check only one of the 3 types of Exit Points. The 3 types are:

Return value is the easiest to test. You trigger an entry point, you get something back, you check the value you get back. A function with only this type of exit point aligns with the Functional programming (FP) principle of having pure functions i.e. functions that have no side effects (the other types of entry points).

State change that is noticeable (and public / external) usually require more effort. You call something that triggers the state change, then you check that the state has changed. An example is a method of a class that changes the value of its public property (a function with side-effects).

Dependency call is the hardest to test as it likely interferes with our tests in one or more of the following way: the tests are hard to write and run, maintain, keep consistent / not brittle, and run fast. Some examples: loggers that write to files, code communicating to the network, code controlled by other teams, code that takes a very long time (calculations, threads, database access). This forces us to isolate the code under test from the dependency by using Fake techniques (described when they occur in other blog posts).

Good unit test summarized

A Good unit test focuses on a observable and meaningful requirement of the Unit of Work and invokes it through an Entry Point, and then checks one of its Exit Points. It can be written easily and runs quickly. It's trustworthy, readable & maintainable. It is consistent as long as the production code we control has not changed.

Most of the tests should be checking return values or state change, thus avoiding checking dependencies as they complicate and worsen the maintainability of the tests. Thus if a function has both a return value and a dependency call, prefer to only check the return value (checking also the dependency likely provides less value, e.g. checking the log output vs the actual calculation). Furthermore you should avoid introducing dependencies into the unit of work (and even state change) to begin with. This aligns with both Object Oriented (OO) and FP principles to strive to minimize dependencies.

Good code architecture

Designing good code architecture is a fundamental skill and valuable on its own, but it is also needed to be applied to reap the benefits of Test-Focused Development. This also goes the other way as TDD (done correctly) pushes for good code design and maintainability (for both the production and the test code). Some principles and patterns have already been mentioned above, let's go through them again and add some more.

  • The FP principle of having pure functions i.e. functions that have no side effects (the other types of entry points than return value). Pure functions promotes effortless test- and maintainability (but is though not a deal breaking requirement for test focused development).
  • Separation of Concerns (SoC)
  • code should be low coupled / have minimal dependencies
  • each unit should have a readable and requirement focused interface and internals should be encapsulated

Designing good code architecture is a broad topic and I won't delve further into to it here and instead recommend the books Clean Code by Robert C. Martin (OO focused) and Functional Programming in Scala by Paul Chiusano and Rúnar Bjarnason (FP focused).

Test-Driven Development

TDD is a workflow discipline that is focused on when to write unit tests and requires 2 underlying skills (Good unit tests and Good code design) to be applied to reap the benefits of TDD (as mentioned above). The heart of TDD is the TDD cycle:

TDD: RED GREEN REFACTOR
The three steps of the TDD cycle
  1. RED: Write a good unit test and see it fail to prove a meaningful functionality is missing in the production code and verifying that the test is not passing when it should not
  2. GREEN: Make the test pass with minimal effort by adding functionality to the production code that meets the expectations of the test.
  3. REFACTOR: Clean up code smells / improve the design and readability (both production and test code). The step should be very small and incremental (if needed), and we run all the tests after each iteration to make sure we didn't break anything with our changes.

The cycle should be repeated until you are confident that the unit of work handles all meaningful cases of a specific requirement. Due to the repeating nature of the cycle then step 2. GREEN can be a naïve functionality as the next cycle will improve and generalize it (with more test cases the more generic the unit of work functionality should become).

Some upfront and in-between cycles thinking about the requirement breakdown and cycle order is needed to avoid missing the big picture and getting stuck. At some interval (every hour or two) we should stop to evaluate if the architecture of the overall system is still sound and clean. If not then iterate the REFACTOR step until it becomes sound and clean again.

There are various meanings of TDD out there so I'll clarify what I mean here by paraphrasing Roy's blog post: TDD cycling the meaningful requirements while also allowing it to evolve your design as an added bonus. You may already have some design in place before starting to code, but it could very well change because the tests point out various smells (Roy coins this as TDDAD (Development and Design) to differentiate from other meanings).

My TDD blog series take on a specific assignment cycle by cycle, and I also recommend the book Test-Driven Development: by Example by Kent Beck.

Why do TDD ?

Done correctly (including with good unit test and design), TDD can provide these benefits:

  1. Clean the code / improve the code design fast and continuously without fear of breaking it.
  2. Decrease the number of bugs and shorten the time it takes to find bugs.
  3. The bulletin 1. and 2. increase the product quality and the team's confidence to ship it and improve it further.
  4. The tests are accurate and up-to-date (developer focused) documentation on the behavior of the units under test.
  5. Less time spent in the debugger.

If TDD is done incorrectly, it can cause the project schedule to slip, waste time, lower motivation, and lower code quality, thus it's a double-edged battle axe, hence there are some criticisms out there on TDD, e.g. the post TDD is dead. Long live testing, the post follow-up YouTube series Is TDD dead ?, and counter responses to it e.g the post Monogamous TDD.

Is TDD suitable for all situations ?

Below are the main situations where test focused development can be tricky to apply (or provide little value like in the prototype phase), but there are ways to achieve it as described below.

Prototype phase code

The focus of prototyping is to get out an idea as fast as possible to get feedback on it. It should be time boxed and at the end of the period it should be decided if the idea should become a real production project or not. If so the prototype code should be thrown away. It is fine to not (or sparsely) use TDD in the prototyping phase if the consensus is it slows things down, but TDD should be used when the production project codebase is started from scratch.

Legacy Code

Legacy code in a sentence is valuable code you are afraid to change. It typically:

  • Has been developed for some time.
  • Is already 'working' (for most common scenarios / most of the time).
  • Has technical debt, e.g. bad code smells and design that has crept in with time (e.g. static things and high coupling).
  • Is hard to read and understand and reason about.
  • Has no or few good unit tests / very low test coverage (or many very bad and fragile tests that are hard to read and understand).

When you need to fix or change legacy code it is hard to do and have the confidence that the change did not break something else. A breakage is discovered late (QA manual testing or live production) which is costly as it always takes effort to get again into the context to fix the bug and a new QA and release cycle takes time.

I think it's hard to prevent technical debt to creep up with time by strictly upholding great design principles without writing unit tests, but using TDD (or test focused development) helps with upholding the prevention as the above sections should convey.

One of the negative consequence of legacy code is that it's very hard and complex to write unit tests for it as the tests likely need a lot of Fakes(described when they occur in other blog posts) and extra code to mute dependency interference in the tests, where static things especially become a pain. Legacy code should be first covered with tests (as painful as it may be), and then refactored into a cleaner state (and meanwhile improving the tests). Then afterwards TDD can be used effortlessly and to it's full potential.

Improving legacy code is a broad topic thus I'll leave it here by referencing the book Working Effectively with Legacy Code and this blog post Starting TDD with legacy code.

Boundary layers

A big part of a game and many apps is the graphic user interface and visual effects, user input and sound. Should these parts be unit tested ? These parts can be described as being close to the physical output / input boundaries layers (e.g. screen, speaker). The design can be driven to make these boundary layers thin and humble that only focus on the responsibility of the boundary (e.g. disable/enable button), and extracting all other logic into other modules that can be easily tested.

It's likely that the logic being extracted is in fact the domain / business logic, the code that makes boundary independent decisions and thus belongs to the domain model and then should be moved there. The domain model should not depend on any boundaries, thus be easily unit testable.

The edges of the boundary layers can be manually tested, e.g making sure the button displays properly on the screen. If boundary bugs begin slipping into the production as the project grows, then it's time to replace some of the manual testing effort with automated tests (e.g integration and / or UI tests). The Test Pyramid could be a guide for that process.

Comments

Fabio Paes Pedro

I'm no expert but the value TDD provides became clear to me quite fast. I believe this requires a mindset/process change though, a different way of thinking things through that might extend outside of our team, into a company level. I can't wait to read the rest of this series!

My response

Thank you for your comment Fabio. I agree that TDD requires a mindset/process change where ideally the whole team and the company are aligned on using TDD to reap its benefits. I aim to continue writing blog post and complete this series, so stay tuned :)