Testing
{:toc}
Strategy for Bessy
The faster a problem is caught, the cheaper it is to fix (because the humans forget what they did over time). We shall employ many layers to achieve this:
- VSCode syntax highlighting (yes, this is a form of testing)
- Unit testing
- Static testing (linting)
- Coding style (formatting, guideline checks)
- Pull requests
- Acceptance testing (ideally automated)
- Manual testing (exploratory testing)
TODO - update as we get these things implemented
Unit Testing
For Bessy Python unittest tool seems to be sufficient for now. As always with Python, there’s lots of alternatives.
Bessy Unity leverages Unity’s Test Runner to run unit tests.
Python Setup
Unit tests live in their own file, the file name must start with “test_”, for example “test_classification.py”. The test name should correspond to the class or unit or topic that is being tested.
The unittest tool follows the same basic paradigm of JUnit, CppUnit, XCTest, etc. A “unit test” consists of a set of test functions that check a small portion of functionality. Each test is run independently from a known state:
import unittest
class TestNumMethods(unittest.TestCase):
def test_abs_of_positive_is_positive(self):
value = 50.0
self.assertEqual(abs(value), 50.0);
def test_abs_of_negative_is_positive(self):
value = -100.0
self.assertEqual(abs(value), 100.0);
You can detect and run all tests like this:
python -m unittest -v
test_abs_of_negative_is_positive (mymodule.test_num.TestNumMethods) ... ok
test_abs_of_positive_is_positive (mymodule.test_num.TestNumMethods) ... ok
----------------------------------------------------------------------
Ran 2 tests in 0.000s
Unity Setup
TODO - explain this… note, there are failing and flaky tests at on Windows.
Unit Test Style
Writing tests is a bit different from writing production code. We want tests to be easily understood when an assertion fails. That means tests should strive for readability over compactness. Key things to observe:
- Test function names explain what is being tested
- Test functions should focus on one thing, avoid “eager” tests
- Structure each test with the arrange, act, assert pattern
- Assertions should exist in the test function, not in a helper
In the example below, note how it’s clear what the test is checking and that there is only one assertion:
def test_can_make_dog_happy(self):
dog = Dog("fido") ## arrange
dog.praise() ## act
self.assertTrue(dog.isHappy) ## assert
If we also wanted to tests that Dog wags its tail and leaps when happy, it’s better to create a new test. While that duplicates code, single assertions make it obvious why a test is failing. This is a key difference between test code and production code. We prefer tests to be explicit at the expense of duplication.
Test driven development
Tests are a powerful tool for guiding software development. By writing a test before you write production code, you are forced to think about how code is used by others. For example, how will the function be called? how are the results provided? how do I tell that the function did what it is supposed to do?
The process is simple:
- Write a single failing test (no more, no less)
- Write enough production code to pass the test
- Refactor
The hard part is having the discipline to follow the process. Avoid the temptation to write all the tests first. Don’t have someone else write the tests. This robs you of feedback from the tests. Just write one test at a time. Each test will provide a little bit of feedback on the chosen design. It’s very normal to get about 3 tests into development and realize that your design needs to change. Let the tests guide you.
When should you use TDD? When writing production code or complicated code. If you’re just hacking something together that’s going to be thrown away in two days, it’s probably not worth it.
Bessy challenge
For Bessy, a lack of unit tests is a challenge because:
- Code was not written with testing in mind
- No tests to check if a change has broken something
Code that was created without unit tests is hard to work with. Creating that first failing test may require significant refactoring, which increases the risk of injecting a bug.
A good strategy when working on code like this is to create a few tests around the area that you will be working. Think of the tests as setting up scaffolding before making changes. These tests can help detect unintended change. Once the scaffolding is up, resume regular test first development can begin - start with a failing test.
Further Reading
Foundations
The biggest hurdle to unit testing is having software that can be tested. All the canned examples on the internet skip this part making it seem way easier than it actually is. The first time you try to add a test to existing code, it’s going to be really hard because the code wasn’t designed for testability. This is a normal part of the experience, and unfortunately, folks give up at this point.
To be proficient at unit testing, you need a solid understanding of how to structure code so that it can be tested. The following books are excellent places to start.
Bob Martin’s book on the “SOLID” principles is a great intro to software design. Dependency Inversion or Injection (the D in SOLID) is a key technique in designing software that can be unit tested. Read the section about SOLID principles. The latter half of the book is about library/package design principles and can be skipped.
Kent Beck’s book on unit testing is really all you need. His example uses Java, but the concept is applicable to any language. Step through the first section, it’s well worth it.
Hands on
These videos do a decent job of showing the TDD technique in real time, but don’t get into design topics. The examples are done using a similar stack to Think2Switch (JavaScript, Node.js and Jest). There’s also a repository with example code so you can go through the “katas” on your own. The first example is a classic software interview question:
- https://www.youtube.com/watch?v=BlT2FeUXeqY
- https://www.youtube.com/watch?v=hTx3bmMsISc
- https://www.youtube.com/watch?v=zqH1uxHL6go
- https://www.youtube.com/watch?v=hQysWBXjBgg
- https://www.youtube.com/watch?v=imLmVM-h2eA
When writing tests, it helps to follow a common format (Greg Wilding update this):
- Arrange act assert (to organize each test into logical steps)
- Avoid eager tests (test one behaviour per test, not several)
- Given, When, Then (to describe what the test is looking for, note - when formulating it can be easier to start with “then” and work backwards)
Mocking (Greg Wilding update)
- Mocks useful to make tests fast, avoid persistence, replace hardware, etc
- in memory database for a real file based one
- a mock headset adapter that allows injecting data into test
- Gerard’s “test doubles” is a useful way to describe what a “mock” object does (spy, fake, stub)
Further reading
This video reflects on the software industry’s adoption of unit testing / TDD and common misconceptions and pitfalls. The presenter assumes previous experience with unit testing.