
Testing The "Untestable"
As your software solution grows in maturity, the number of tests required to ensure full test coverage can grow exponentially. This is because each unit of code must be tested in isolation before its integration with other parts of the codebase is tested. As the number of tests grows, so too does the duration of a single test run. This is why unit tests are so crucial to an efficient development cycle, not only can they concisely locate bugs, they also provide immediate feedback. This rapid feedback allows developers to quickly test that new code behaves as expected without breaking any existing functionality.
Untestable units of code
When we talk about what a unit test is, we can reduce its purpose to a simple definition - given we provide specific input to a specific function, we should receive a specific output or return value. This logic seemingly breaks down in certain circumstances.
- Fragile functions - testing a function that may fail due to external factors such as network issues or expired API keys
- Nondeterministic functions - testing a function that does not produce a predictable result, such as functions that use random numbers
- Mutator functions - testing a function that has no return value or that mutates a property or performs an action against some resource which can not be queried (such as a function which makes a POST request or writes to a log file).
Mocking and Patching - testing the “untestable”
A mock object is a fake representation of a real object within your solution. Using a mock object instead of its real implementation allows us more control over the behavior of the code. If we take the fragile function scenario from above, a temporary network issue may result in intermittent failures in our tests that require external API calls. In order to establish more control, we can use a mock object to simulate our external services. Although using a mock will result in the external service never being invoked, we can test that the right methods are called the right number of times with the right arguments. Patching intercepts import statements, returning a mock instance that we can configure to establish more control over our test environment.
Hands-on Python examples
Let's take a look at some real examples where mocking can help us test seemingly untestable (or at least difficult to test) functions.
The Student class
This is a simple class, it has a constructor that takes in a name and a grade, and setters and getters which either modify or return a property value (respectively). Each function also logs the associated action for auditing purposes.
So the question is how can we test this logic. The getters, also known as accessor methods, are straightforward enough to test, you simply call the getter function and assert that the returned value matches what you expect. The setters, also known as mutator methods, are also simple in theory, the issue is that after calling a setter, your test will also require a call to a corresponding getter function to confirm that it has worked. This means a flaw in the getter logic could potentially lead you to believe the setter logic is broken.
But what about the audit logs, where we have no setters or getters to leverage. This could be covered by integration tests - where we parse an actual log file to check the contents, however we are interested in immediate feedback, so instead we utilize mocking to unit test the logger logic.
By mocking the debug and info logger, we can check how many times they have been invoked and the arguments they have been invoked with. The code shows that we have patched different parts of the logger, and performed some assertions around how many times they are called in the code and what arguments have been passed to them.
It is important to note that the order of your patches should be reversed, as they are applied bottom up. Check the official python docs to read more about this nested patch decorator behavior.
Working with Databases
Let's apply what we’ve learned to another common use case. Use the following MySQL commands to create a database called book_store and added some entries to the books table. The challenge is now how do we test that our python code interacts with this database correctly?
First, write the python code to read from, and write to the database. The following code assumes you have used root as the username and the password and are working with a database deployed to a local instance of MySQL:
Next come the tests. If we attempt to actually connect to the database in our test, we run the risk of the tests failing due to the database not being created, the table not being created, mysql not being installed locally, or the install being configured incorrectly. We also run the risk of really slow queries if the table size grows dramatically. We once again turn to mocking.
You’ll notice that in these tests, we have tested logic which has no direct return value. We test that the call to the mutator (write_to_db) results in the execute function being invoked a specific number of times with very specific parameters. For example with our write to database logic, we can at least confirm that the insert query is called once with the correct insert query.
File IO
Finally we look at file IO, which historically has been notoriously difficult to unit test. The basic logic for basic file IO is trivial
To test this logic without actually interacting with the underlying file system, we use some out of the box mocking functions.
The built in open allow us to mock the read and write functions associated with file IO. This means we can easily test units of code that incorporate the file system. It is worth exploring the official python documentation to see what other hacks and tricks are available.
Conclusion
These hands-on examples should highlight the simplicity of mocking to test code deemed difficult to test, specifically, mutating, indeterministic or fragile functions. Rather than leaving all the heavy lifting to integration tests, developers can mock calls using the patch decorator, and make sure that each call is invoked the correct amount of times using the correct arguments. The immediate feedback provided by unit tests can help developers produce better code quickly and provide all parties with a peace of mind due to increased code coverage.
Snowmate - Your Whitebox Testing Mate
What if you had the option to continuously validate your code by adding tests that simulate actual data, significantly reduce the time of writing tests, and add coverage and validation to the code in real-time. If this interests you, sign up now.