Learning unit testing doesn’t stop at mastering the technical bits of it, such as your favorite test framework, mocking library, and so on. There’s much more to unit testing than the act of writing tests. You always have to achieve the best return on the time you invest in unit testing, minimizing the effort you put into tests and maximizing the benefits they provide. Achieving both things isn’t an easy task.
- They grow effortlessly, don’t require much maintenance, and can quickly adapt to their customers’ ever-changing needs.
The ratio between the production code and the test code could be
anywhere between 1:1
and 1:3
.
Coverage limitations
- You can’t guarantee that the test verifies all the possible outcomes of the system under test.
- No coverage metric can take into account code paths in external libraries.
The goal of unit testing
- lead to a better code design
- enable sustainable(可持续的) growth of the software project
the cost components of writing unit tests:
- refactoring the test when you refactor the underlying code
- running the test on each code change
- dealing with false alarms raised by the test
- spending time reading the test when you’re trying to understanding how the underlying code behaves
a successful test suite must:
- integrated into the development cycle
- targets only the most important parts of the code base
- 👉🏻 domain logic
- infrastructure code
- external services and dependencies
- code that glues everything together
- provides maximum value with minimum maintenance costs
- recognize a valuable test (and, by extension, a test of low value)
- write a valuable test
What is a unit test?
- verifies a single unit of behavior
- dose it quickly
- dost it in isolation from other tests
An integration test, then, is a test that doesn’t meet one of these criteria.
End-to-end tests are a subset of integration tests.
How to structure a unit test?
Type | Components |
---|---|
AAA | Arrange - Act - Assert |
GWT | Given - When - Then |
- avoid multiple arrange, act, and assert sections.
- avoid if statements in tests.
- name the test as if you were describing the scenario to a non-programmer who is familiar with the problem domain.
- separate words with underscores.
- structure a test is to make it tell a story about the problem domain
Four pillars of a good unit test
code is not an asset, it’s a liability.
protection against regressions
- the amount of code that is executed during the test
- the complexity of that code
- the code’s domain significance
resistance to refactoring
- the fewer false positives the test generates, the better
- tests provides an early warning when you break exisiting functionality
- you become confident that your code changes won’t lead to regressions
- the more the test is coupled to the implementation details of the system under set(SUT), the more false alarms it generates
- you need to make sure the test verifies the end result the SUT delivers: its observable behavior, not the steps it takes to do that.
- the best way to structure a test is to make it tell a story about the problem domain
fast feedback
maintainability
- how hard it is to understand the test, which is a function of the test’s size
- how hard it is to run the test, which is a function of how many out-of-process dependencies the test works with directly
The intrinsic connection between the first two attributes
Type II Error:
- if functionality is broken, the test should fail, but if the test also passed, means it is not a good unit test, should if it fails, means it offers protection against regressions.
Type I Error:
- if functionality is correct but the test fails, means that the test dose not test the nature of behavior. The good unit test should always pass when the functionality is correct. This would help us a lot when we try to do refactor. If we refactor the code correctly, but the unit tests always failed, means that the unit tests are not good enough, we need to optimize them.
An ideal test
- value =
[0..1] * [0..1] * [0..1] * [0..1]
(corresponding to the four pillars)
black-box and white-box testing
- choose black-box testing over white-box testing by default.
- the only exception is when the test covers utility code with high algorithmic complexity
- use code coverage tools to see which code branches are not exercised, but then turn around and test them as if you know nothing about the code’s internal structure.
Mock
types of test doubles
- mock: help to emulate and examine
outcoming
interactions —— change state- mock: generated by tools
- spy: written manually
- stub: help to emulate
incoming
interactions —— get input data- stub: can configure to return different values for different scenarios.
- dummy: a simple, hardcoded value such as a null value or a made-up string.
- fake: the same as a stub for most purposes, only except for its creation, it is usually implemented to replace a dependency that dose not yet exist
never asserting interactions with stubs.
Observable behavior
- expose an
operation
that helps the client achieve one of its goals. An operation is a method that performs a calculation or incurs a side effect or both. - expose a
state
that helps the client achieve one of its goals. State is the current condition of the system.
Whether the code is observable behavior depends on who its client is and what the goals of that client are.
Ideally, the system’s public API surface should coincide with its observable behavior, and all its implementation details should be hidden from the eyes of the clients.
Mocks and test fragility
Intra-system
communications are communications between classes inside your application.Inter-system
communications are when your application talks to other applications.
- The use of mocks is
beneficial
when verifying the communication pattern between your system and external applications. - Using mocks to verify communications between classes inside your
system results in tests that couple to implementation details and
therefore fall short of the
resistance-to-refactoring
metric.
Types of dependencies
shared dependency
: a dependency shared by test (not production code)out-of-process dependency
: a dependency hosted by a process other than the program’s execution process (database, stmp server)private dependency
: any dependency that is not shared
Styles of unit testing
output-based
: only need to verify the output.state-based
: the underlying code changes its own state, the state of its collaborators, or the state of an out-of-process dependency.communication-based
: use mocks to verify communications between the SUT and its collaborators, to verify the communication situations.
compare
protection against regressions
- for the most part, they are not very different
- but overusing the
communication-based
style can result in shallow tests that verify only a thin slice of code and mock out everything else.
fast feedback
- for the most part, they are not very different
communication-based
testing can be slightly worse because the cost of mocks.
resistance to refactoring
state-based
is the best one.communication-based
is the worse one, because it is the most vulnerable to false alarms.
maintainability
output-based
is the best one, because they do not deal with out-of-process dependencies.state-based
is less maintainable because state verification takes up more space than output verification.communication-based
is the worst one, it requires setting up test doubles and interaction assertions, and that takes up a lot of space.
functional architecture
*Functional architecture
* maximizes the amount of code
written in a purely functional (immutable) way, while minimizing code
that deals with side effects. Immutable means unchangeable:
once an object is created, its state can’t be modified. This is in
contrast to a *mutable* object (changeable object), which can be
modified after it is created.
Separate two kinds of code:
- code that make a decision
- code that acts upon that decision
Tips
- Moving from using an out-of-process dependency to using mocks.
- Moving from using mocks to using functional architecture
Four kinds of code
- Domain model and algorithms
- Trivial code
- Controllers
- Overcomplicated code
Tips
- always write completed unit tests for
domain model the algorithms
code - never test
trivial code
- write integration test for
controllers
- do not write overcomplicated code, try to separate it into
domain model and algorithms
andcontrollers
Trade-off
- domain model testability
- controller simplicity
- performance
- push all external reads and writes to the edges anyway
- inject the out-of-process dependencies into the domain model
- split the decision-making process into more granular
steps 👈
CanExecute/Execute
pattern- domain events
CanExecute/Execute pattern
You can use CanExecute/Execute
pattern to balance the
performance
and testability
, but concedes
controller simplicity, but it is manageable in most cases.
Domain events
Domain events help track important changes in the domain model, and then convert those changes to calls to out-of-process dependencies. This pattern removes the tracking responsibility from the controller.
- extract a
DomainEvent
base class and introduce a base class for all domain classes, which would contain a collection of such events:List<DomainEvent> events
Integration tests
- check as many of the business scenario’s edge cases as possible with unit tests
- use integration tests to cover one happy path, as well as any edge cases that can’t be covered by unit tests
- if there’s no one path that goes through all happy paths, write additional integration tests—as many as needed to capture communications with every external system
- attempt to apply the
fail-fast principle
as a viable alternative to integration test.
two types of out-of-process dependencies
managed dependencies
: only accessible through your application. it is implement details and should not be mock.unmanaged dependencies
: you don’t have full control over it. It is observable behavior and you should mock it.
interface misunderstand
🙋🏻♀️ Genuine abstractions are discovered, not invented.
👉🏻 For an interface to be a genuine abstraction, it must have at lease two implemtations.
The common reasoning behind the use of interfaces is that they help to:
- Abstract out-of-process dependencies, thus achieving loose coupling.
- Add new functionality without changing the existing code, thus
adhering to the
Open-Closed principle
Misconceptions:
- Interfaces with a single implementation are not abstractions and don’t provide loose coupling any more than concrete classes that implement those interfaces.
- The second reason violates a more foundational principle:
YAGNI (You are not gonna need it)
. - The only reason to use interfaces for out-of-process dependencies it
is to
enable testing
! - Do not introduce interfaces for out-of-process dependencies unless you need to mock out those dependencies.
integration test best practices
- making domain model boundaries explicit
- reducing the number of layers in the application
- eliminating circular dependencies
maximozing mock’s value
when mocking, always try to verify interactions with unmanaged dependencies at the very edges of your system.
Mocking
IBus
instead ofIMessageBus
maximizes the mock’s protection against regressions.A call to an unmanaged dependency goes through several stages before it leaves your application. Pick the last such stage. It is the best way to ensure backward compatibility with external systems, which is the goal that mocks help you achieve.
In some cases, you can use
spy
instead ofmock
for more succinct and expressive.1
2
3
4
5
6
7
8
9
10
11[ ]
public void Changing_email_from_corporate_to_non_corporate()
{
var busSpy = new BusSpy();
var messageBus = new MessageBus(busSpy);
var loggerMock = new Mock<IDomainLogger>();
var sut = new UserController(db, messageBus, loggerMock.Object);
/* ... */
busSpy.ShouldSendNumberOfMessages(1)
.WithEmailChangedMessage(user.UserId, "new@gmail.com");
}
mocking best practices
- applying mocks to unmanaged dependencies only
- verifying the interactions with those dependencies at the very edges of your system
- using mocks in integration tests only, not in unit test
- always verifying the number of calls made to the mock
- do not rely on production code when making assertions. Use a separate set of literals and constants in tests.
- mocking only types that you own
- always write your own adapters on top of third-party libraries and mock those adapters instead of the underlying types.
- only expose features you need from the library
- do that using your project’s domain language
- this guideline dose not apply to in-process dependencies. There is no need to abstract in-memory or managed dependencies. Similarly, there’s no need to abstract an ORM as long as it’s used for accessing a database that isn’t visible to external applications.
Testing the database
prerequisites
keeping the database in the source control system
- database schemas
- reference data
using a separate database instance for every developer
applying the migration-based approach to database delivery
applying every modification to the database schema (including reference data) through migrations. Do not modify migrations once they are committed to the source control. If a migration is incorrect, create a new migration instead of fixing the old one. Make exceptions to this rule only when the incorrect migration can lead to data loss.
transaction
split the Database
class into repositories
and a transaction
:
repositories
are classes that enable access to and modification of the data in the database.transaction
is a class that either commits or rolls back data updates in full. This will be a custom class relying on the underlying database’s transactions to provide atomicity of data modification.
tips
- use at least three transactions or units of work in an integrations test: one per each arrange, act and assert section.
- your tests should not depend on the state of the database. Your tests should bring that state to the required condition on their own.
- create two collections for unit and integrations, and then disable test parallelization in the collection with the integration test.
- clean up data at the beginning of a test
- write the SQL script manually. It’s simpler and gives you more granular control over the deletion process.
- the best way to shorten integration is by extracting technical, non-business-related bits into private methods or helper classes.
- only the most complex or important read operations should be test, disregard the rest.
- do not test repositories directly, only as part of the overarching integration test suite.
Unit testing anti-patterns
⚠️ Do not do the things like below!
unit testing private methods
Private methods are implementation details! Just test observable behaviors!
If the private method is too complex to be tested as part of the public API that uses it, that’s an indication of a missing abstraction. Extract this abstraction into a separate class instead of making the private method public.
expose private state
leaking domain knowledges to tests
1
2
3
4
5
6
7
8
9
10
11
12
13public class CalculatorTests
{
[ ]
public void Adding_two_numbers()
{
int value1 = 1;
int value2 = 3;
int expected = value1 + value2; // <-----The leakage
// int expected = 4 // the better one
int actual = Calculator.Add(value1, value2);
Assert.Equal(expected, actual);
}
}code pollution
Code pollution is adding production code that’s only needed for testing.
mocking concrete classes
working with time