Testable Components
Classes are the starting point for testing since they are the ideal "unit" for it. Each class should be able to test itself. Production code and test code should be written together.
Testability
The interface of a class must support testing. Access to the state or the interaction flow is essential for the verification and validation of the code. The class must be isolable (isolated from its dependencies). This is much easier with abstract dependencies that can be replaced (concrete dependencies might not). Small and focused methods facilitate testing.
Global mutable state (e.g., Singleton) should be avoided because it can hardly be replaced by stub/mock. The database is also some kind of a global mutable state. Furthermore, deep inheritance hierarchies should be avoided because they also complicate testing.
SOLID Principles for Testing
- Single Responsibility: Focused code is much easier to test
- Open-Closed: Changing the behavior of a class without source code changes facilitates testing (e.g., providing a test double)
- Liskov Substitution: Contract tests written for an interface work for all implementations of it
- Interface Segregation: Small and focused interfaces make it easier to implement test doubles
- Dependency Inversion: Abstractions can be replaced by test doubles (only works if classes depend on abstractions)
Testing & Inheritance
The example below illustrates how inheritance can affect testing. It is obvious that there must be tests for Account.transactionAverage(), Account.transactionDays and Account.transactionSum. Also if there is a new subclass called SpecialAccount it is also trivial that its method SpecialAccount.transactionDays() must be tested.
However, since Account.transactionAverage() makes use of transactionDays() it is therefore also required to write another test which handles that test case.
public class Account {
public BigDecimal transactionAverage(LocalDate since) {
// this expression might lead to division by zero when used with// transactionDays() in SpecialAccount
return transactionSum(since)
.divide(BigDecimal.valueOf(transactionDays(since));
}
protected long transactionDays(LocalDate since) {
return DAYS.between(LocalDate.now(),since) + 1;
}
private BigDecimal transactionSum(LocalDate since) { ... }
}
public class SpecialAccount extends Account {
// this method might return 0 if since == LocalDate.now
protected long transactionDays(LocalDate since) {
return DAYS.between(LocalDate.now(), since);
}
}
Creating Test Objects
In production code most domain objects are either read from the database or converted from service input parameters. The test code needs a lot of these objects and their variations. Thus, object creation must be facilitated.
public class Voucher {
public Voucher(String code, int maxRedemptions, int discount, LocalDate creationDate, LocalDate, expiry Date, long campaignId, long partnerId) { ... }
}
// when we want to test the voucher service we need to create a lot of different new vouchers
public class VoucherService {
public void addVoucher(Voucher voucher) { ... }
}
// without knowing all parameters of the constructor we don't know what these parameters stand for
new Voucher("4711", 10, 50, LocalDate.now(), LocalDate.of(2030, 12, 31), 22, 32);
// Fluent API (is a bit verbose...)
new Voucher().code("4711").maxRedemptions(10)
.discount(50).creationDate(LocalDate.now())
.expiryDate(LocalDate.of(2030, 12,31))
.campaignId(22).partnerId(32);
// Factory for frequently used objects
createExpiredVoucher();
// Default object with variation
createDefaultVoucher().code("4711")
Testing Context
Testing needs to be able to decouple from the (production) context. Objects that are collaborating (i.e. dependencies) may require resources (in the context). However, they might not be available (e.g., payment service) or are too slow or cumbersome for testing (e.g., database, service). Furthermore, the collaborating objects may not provide API to verify behavior or state.
Thus, abstract coupling is a prerequisite for decoupling. This can be achieved by using interfaces for abstract coupling and making dependencies configurable. Context specific dependencies from the outside can be provided using a constructor, factory or dependency injection as illustrated in the example below.
// hard-coded dependency
public class AccountReportService {
private IAccountService accountService = new AccountService();
public AccountReport compileAccountReport() {
// using accountService to compile report
}
}
// configurable dependency
public class AccountReportService {
private IAccountService accountService;
public AccountReport(IAccountService accountService) {
// AccountService can be provided from the outside
// this allows replacing it during testing
this.accountService = accountService;
}
}
// using a factory
public class AccountReportService {
private IAccountService accountService = getServiceFactory().getAccountService();
}
// using dependency injection (Spring)
public class AccountReportService {
@Autowired
IAccountService accountService;
}
When production classes are subclassed for testing this is usually a bad sign (e.g. by overriding the factory method). Because then the subclass is tested and not the production class. The part that is overridden should live outside of the class and should be provided based on the context.
Creating test doubles for testing is expensive (e.g., because all the interfaces need to be implemented). Therefore, mocking frameworks exist to help replace dependencies with test doubles. There are two schools thought when it comes to these frameworks:
- Behavior: only behavior is important (Mocks)
- State verification: if state is correct, then i do not have to care about behavior (Stubs)
Mocks
A mock mimics the behavior of a real class. It pretends to be a real class. It returns canned answers (ready-made) to requests (e.g. if there is a getter, the mock returns a predefined value). This is only required as far as an answer is needed for "Component under Test" (CuT).
Test verification is accomplished by verifying the behavior. The interaction of the CuT with the mock is recorded and the verification happens after completion of the interaction scenario. The behavior might also be specified when creating the mock. The mock then verifies it while the CuT interacts with it (and throws an exception on unexpected interaction).
Mock-style testing always replaces all real dependencies with mocks.
Stub
The stub also mimics the behavior of a real class and returns canned answers to requests. However, test verification is accomplished by state verification. This might require additional methods for accessing the state. The test only cares about the final state (and not how this state was achieved).
Stub-style testing replaces real dependencies with stubs only if the real class has severe context dependencies, is slow (e.g., database) or the state is inaccessible for verification.
Stubs are more robust than mocks since different interactions might lead to the same final state (mock fails, stub passes).
