validate methods. Additionally if you find yourself being unable to exercise all paths of
a private method in testing that may be a sign of dead code or overly complex logic. If you are certain that all paths are reached in real world
usage of the method, try recording inputs to the method in the real world and then use those in your tests. If all else fails extracting the method to
a package private class or detail namespace and testing there is preferable to making the function public.
Continuing on from black-box testing is mock testing, mock testing is the antithesis of black-box testing where you assert that methods are invoked with specific parameters in specific orders. I argue that mock tests are always more harmful than any other form of testing, if you imagine your functions and classes as blocks of lego then mock testing is superglue. Any code change requires changing a dozen mock tests and more importantly you have no clue if the code change works. Mock tests verify nothing other than that the code is exactly what you wrote when you wrote the test. Tests are supposed to provide a level of confidence that a change has been made without changing existing behaviour in an undesirable way, mock tests allow no code changes to happen and therefore provide no confidence as the tests immediately fail on any change.
@Component
public interface GlacierClient {
Vault describeVault(String name);
Vault createVault(String name);
}
@Component
@AllArgsConstructor
public class LogArchiver {
public static final String LOG_ARCHIVE_VAULT = "LogArchiveVault";
@Autowired
private final GlacierClient glacier;
public void createLongTermLogStorage() {
Vault vault = glacier.describeVault(LOG_ARCHIVE_VAULT);
if (valut == null) {
vault = glacier.createVault(LOG_ARCHIVE_VAULT);
}
}
}
public class LogArchiverTest {
@Mock
private GlacierClient glacierClient;
@Autowired
private LogArchiver logArchiver;
@Test
public void testCreateLongTermStorageCreatesVaultIfNeeded() {
// If the vault doesnt exist
expect(glacierClient.describeVault(LogArchiver.LOG_ARCHIVE_VAULT)).andReturn(null);
// then it should be created
expect(glacierClient.createVault(LogArchiver.LOG_ARCHIVE_VAULT)).andReturn(new Vault(LogArchiver.LOG_ARCHIVE_VAULT));
logArchiver.createLongTermLogStorage();
replay(glacierClient);
}
@Test
public void testCreateLongTermStorageDoesNotCreateVaultTwice() {
// If the vault doesnt exist then the vault should not be created again
expect(glacierClient.describeVault(LogArchiver.LOG_ARCHIVE_VAULT)).andReturn(new Vault(LogArchiver.LOG_ARCHIVE_VAULT));
logArchiver.createLongTermLogStorage();
replay(glacierClient);
}
}
This demonstrates very basic mocking to ensure that a function calls specific apis, it also demonstrates that the test is tightly
coupled to not only the functions interface, but its implementation and the interface of its consumed dependencies. This makes changing code hard
and will require rewriting all tests when changing the behaviour of this function, which ensures that new manual testing will be required as the
test code can no longer be trusted since its changed. In this way mock testing is good at preventing regressions as making any change becomes such a
chore that people choose to not change code if they can at all help it.
Additionally mock tests necessitate duplicate tests when you come to write your full end-to-end tests, as mocks peer inside dependencies to see what happened
it is impossible to directly migrate them into a system test where you dont have access to the internals of your dependencies.
Using virtual dependencies can aleviate all of these issues, and also comes with its own benefits. Assuming the same implementation here is an example
of virtual dependencies.
@Component
public class VirtualGlacierClient implements GlacierClient {
private Connection jdbcConnection;
private static class VaultRecord {
public String name;
}
public VirtualGlacierClient() {
this.jdbcConnection = DriverManager.getConnection("jdbc:sqlite:glacier.db");
try (Statement statement = connection.createStatement()) {
statement.execute("CREATE TABLE IF NOT EXISTS vaults (id INTEGER PRIMARY KEY, name TEXT, CONSTRAINT ak_name UNIQUE (name))");
statement.execute("DELETE FROM vaults;");
}
}
@Override
public Vault describeVault(String name) {
try (PreparedStatement statement = connection.prepareStatement("SELECT * FROM vaults WHERE name = ?")) {
statement.setString(1, name);
try (ResultSet results = statement.executeQuery()) {
if (results.next()) {
String name = results.getString("name");
return new Vault(name);
}
}
}
return null;
}
@Override
public Vault createVault(String name) {
try (PreparedStatement statement = connection.prepareStatement("INSERT INTO vaults (name) VALUES (?)")) {
statement.setString(1, name);
// Throws a unique key constraint if a duplicate vault is created
statement.executeUpdate();
return new Vault(name);
}
}
}
public class LogArchiverTest {
@Autowired
private VirtualGlacierClient glacierClient;
@Autowired
private LogArchiver logArchiver;
@Test
public void testCreateLongTermStorageCreatesVaultIfNeeded() {
// The vault should not exist beforehand
Assertions.assertNull(glacierClient.describeVault(LogArchiver.LOG_ARCHIVE_VAULT));
logArchiver.createLongTermLogStorage();
// The vault should be created
Assertions.assertNotNull(glacierClient.describeVault(LogArchiver.LOG_ARCHIVE_VAULT));
}
@Test
public void testCreateLongTermStorageDoesNotCreateVaultTwice() {
// Create the vault beforehand
glacierClient.createVault(LogArchiver.LOG_ARCHIVE_VAULT);
logArchiver.createLongTermLogStorage();
Assertions.assertNotNull(glacierClient.describeVault(LogArchiver.LOG_ARCHIVE_VAULT));
}
}
While this code is obviously longer than the version with mocks I argue it comes with many benefits.
LogArchiver is reworked to delegate its vault creation logic to another dependency, this test would continue to function.