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.
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).
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:
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).
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.
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.
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).
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:
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.
Done correctly (including with good unit test and design), TDD can provide these benefits:
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.
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.
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 in a sentence is valuable code you are afraid to change. It typically:
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.
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.
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!
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 :)