This is the second story of Road to Simplicity. And it’s about the role of tests in software writing.
(The first part is about the goal of the series and hexagonal architecture)
I think that it’s inappropriate to associate test with verification of correctness. After all with a test we can verify that a software module (e.g. a function) returns an expected output with a given input. But we cannot prove correctness in this way. We can prove correctness mathematically. And this is what we do to prove algorithms correctness.
We can see tests as experiments on the software system. And the below quote expresses concisely the above concept:
No amount of experimentation can ever prove me right;
a single experiment can prove me wrongAlbert Einstein
So, at most, a test could prove wrongness…
Software aren’t static system. They are in evolution. We can add or
delete a feature. We can change its structure. And it changes according the solved problem. There are a lot of reason to change.
However, with an evolution, we can break something that worked in the past. This event is called regression. Eventually we can find regressions manually. But this is a bad road because we could change:
For these reasons we need a tool that guarantees to us non-regression. This tool is test.
Tests free us from the fear of change. Indeed if a test fail we can investigate and fix the issues. Otherwise we can rest assured: we didn’t break something that worked in the past. This implies code malleability.
This is the most evident advantage. But there is another subtler.
The goal of Road to Simplicity is to express a methodology to reach simplicity. And there is a connection with tests.
In the last story we already defined a use case. In reality I write use case and its test simultaneously.
These tests are crucial because use cases represents what our software does. And they guarantees simplicity because when we write tests we’re the client of ourselves. And, as programmers, we love simple and clean interface. Furthermore, because test is more code, we don’t want to write unnecessary lines. In this way we are forced to write the most minimal and simplest interface.
In this series I use a demo project to express concretely these ideas. It's written in Java 11 with reactive programming thanks to ReactiveX. The project is hosted on github.
The software analyzes the capabilities (e.g. java version) of the machine. Then it will expose them through REST API.
In the previous part we expressed our use case (GetCapabilitiesUseCase) as:
public interface GetCapabilitiesUseCase {
Single<Capabilities> getCapabilities();
}
So here the first test definition. I’m using JUnit 5 with ReactiveX test facility. It verifies that the returned capabilities object is correct. We are passing to the use case factory two mocked port out implementations:
class GetCapabilitiesUseCaseTest {
@Test
void ok() throws Throwable {
String javaVersion = randomString();
Long networkSpeed = randomLong();
GetCapabilitiesUseCase useCase = UseCaseFactory.getCapabilitiesUseCase(
() -> Single.just(javaVersion),
() -> Single.just(networkSpeed)
);
TestObserver<Capabilities> useCaseObserver = useCase
.getCapabilities()
.test();
assertTrue(useCaseObserver.await(100, TimeUnit.MILLISECONDS));
useCaseObserver
.assertResult(new Capabilities(javaVersion, networkSpeed));
}
private String randomString() {
return UUID.randomUUID().toString();
}
private Long randomLong() {
return ThreadLocalRandom.current().nextLong();
}
}
During the writing I discovered that there is a room of improvement. Currently our use case could return negative network speed.
This is possible because the Capabilities class allows these values. So I improved its constructor with an exception:
@Value
@Builder
public class Capabilities {
public Capabilities(String javaVersion, Long networkSpeed) {
this.javaVersion = javaVersion;
this.networkSpeed = networkSpeed;
if (this.networkSpeed < 0L) throw new IllegalArgumentException("Network speed should be greater than 0!");
}
private final String javaVersion;
private final Long networkSpeed;
}
Then I added the test class about Capabilities:
class CapabilitiesTest {
@Test
void with_negative_network_speed() throws Throwable {
String javaVersion = randomString();
Long networkSpeed = randomNegativeLong();
try {
new Capabilities(javaVersion, networkSpeed);
throw new IllegalStateException("Invalid capabilities built!");
} catch (IllegalArgumentException e) {
}
}
private String randomString() {
return UUID.randomUUID().toString();
}
private Long randomNegativeLong() {
return ThreadLocalRandom.current().nextLong(1L, Long.MAX_VALUE) * -1L;
}
}
And I updated the use case test:
class GetCapabilitiesUseCaseTest {
@Test
void ok() throws Throwable {
String javaVersion = randomString();
Long networkSpeed = randomNonNegativeLong();
GetCapabilitiesUseCase useCase = UseCaseFactory.getCapabilitiesUseCase(
() -> Single.just(javaVersion),
() -> Single.just(networkSpeed)
);
TestObserver<Capabilities> useCaseObserver = useCase
.getCapabilities()
.test();
assertTrue(useCaseObserver.await(100, TimeUnit.MILLISECONDS));
useCaseObserver
.assertResult(new Capabilities(javaVersion, networkSpeed));
}
private String randomString() {
return UUID.randomUUID().toString();
}
private Long randomNonNegativeLong() {
return ThreadLocalRandom.current().nextLong(0L, Integer.MAX_VALUE);
}
}
I run the tests and they are successful.
At this point we can extend our domain without fear of regressions. We have tests that protect us. Furthermore we have a neat use case interface.
For this story that’s all. I like to stress that tests are not for the present. We are not verifying with them software correctness. Tests are for the future because they guarantee malleability, extensibility and maintainability. At the end they guarantee code simplicity.
Stay tuned! :D