Skip to main content

Testing with Stubs

It can be incredibly tiring to write tests for the database layer.

Often you want to split your code into pure/effectful parts and just test the pure parts, but sometimes you want to observe mutations in the database as well.

Sometimes spinning up a real database for this is the right answer, sometimes it's not. It is always slow, however, so it's way easier to get a fast test suite if you're not doing it.

The argument for the approach taken by Typr is that since the interaction between your code and the database is guaranteed to be correct*, it is less important to back your tests with a real database.

This leads us to stubs (called mocks in the generated code), implementations of the repository interfaces backed by a mutable Map. This can be generated for all tables with a primary key.

Generated RepoMock​

For every repository, Typr generates a mock implementation:

EmployeeRepoMock
public record EmployeeRepoMock(
java.util.function.Function<EmployeeRowUnsaved, EmployeeRow> toRow,
HashMap<EmployeeId, EmployeeRow> map)
implements EmployeeRepo {
public EmployeeRepoMock(java.util.function.Function<EmployeeRowUnsaved, EmployeeRow> toRow) {
this(toRow, new HashMap<EmployeeId, EmployeeRow>());
}

public EmployeeRepoMock withToRow(
java.util.function.Function<EmployeeRowUnsaved, EmployeeRow> toRow) {
return new EmployeeRepoMock(toRow, map);
}

public EmployeeRepoMock withMap(HashMap<EmployeeId, EmployeeRow> map) {
return new EmployeeRepoMock(toRow, map);
}

@Override
public DeleteBuilder<EmployeeFields, EmployeeRow> delete() {
return new DeleteBuilderMock<>(
EmployeeFields.structure,
() -> new ArrayList<>(map.values()),
DeleteParams.empty(),
row -> row.id(),
id -> map.remove(id));
}

@Override
public Boolean deleteById(EmployeeId id, Connection c) {
return Optional.ofNullable(map.remove(id)).isPresent();
}

@Override
public Integer deleteByIds(List<EmployeeId> ids, Connection c) {
var count = 0;
for (var id : ids) {
if (Optional.ofNullable(map.remove(id)).isPresent()) {
count = count + 1;
;
}
}
;
return count;
}

@Override
public EmployeeRow insert(EmployeeRow unsaved, Connection c) {
if (map.containsKey(unsaved.id())) {
throw new RuntimeException("id " + unsaved.id() + " already exists");
}
map.put(unsaved.id(), unsaved);
return unsaved;
}

@Override
public EmployeeRow insert(EmployeeRowUnsaved unsaved, Connection c) {
return insert(toRow.apply(unsaved), c);
}

@Override
public Long insertStreaming(Iterator<EmployeeRow> unsaved, Integer batchSize, Connection c) {
var count = 0L;
while (unsaved.hasNext()) {
var row = unsaved.next();
map.put(row.id(), row);
count = count + 1L;
}
;
return count;
}

/** NOTE: this functionality requires PostgreSQL 16 or later! */
@Override
public Long insertUnsavedStreaming(
Iterator<EmployeeRowUnsaved> unsaved, Integer batchSize, Connection c) {
var count = 0L;
while (unsaved.hasNext()) {
var unsavedRow = unsaved.next();
var row = toRow.apply(unsavedRow);
map.put(row.id(), row);
count = count + 1L;
}
;
return count;
}

@Override
public SelectBuilder<EmployeeFields, EmployeeRow> select() {
return new SelectBuilderMock<>(
EmployeeFields.structure, () -> new ArrayList<>(map.values()), SelectParams.empty());
}

@Override
public List<EmployeeRow> selectAll(ConnectionRead c) {
return new ArrayList<>(map.values());
}

@Override
public Optional<EmployeeRow> selectById(EmployeeId id, ConnectionRead c) {
return Optional.ofNullable(map.get(id));
}

@Override
public List<EmployeeRow> selectByIds(List<EmployeeId> ids, ConnectionRead c) {
var result = new ArrayList<EmployeeRow>();
for (var id : ids) {
var opt = Optional.ofNullable(map.get(id));
if (opt.isPresent()) {
result.add(opt.get());
}
}
;
return result;
}

@Override
public Map<EmployeeId, EmployeeRow> selectByIdsTracked(List<EmployeeId> ids, ConnectionRead c) {
return selectByIds(ids, c).stream()
.collect(Collectors.toMap((EmployeeRow row) -> row.id(), Function.identity()));
}

@Override
public Optional<EmployeeRow> selectByUniqueEmail(EmailAddress email, ConnectionRead c) {
return new ArrayList<>(map.values()).stream().filter(v -> email.equals(v.email())).findFirst();
}

@Override
public UpdateBuilder<EmployeeFields, EmployeeRow> update() {
return new UpdateBuilderMock<>(
EmployeeFields.structure,
() -> new ArrayList<>(map.values()),
UpdateParams.empty(),
row -> row);
}

@Override
public Boolean update(EmployeeRow row, Connection c) {
var shouldUpdate =
Optional.ofNullable(map.get(row.id())).filter(oldRow -> !oldRow.equals(row)).isPresent();
if (shouldUpdate) {
map.put(row.id(), row);
;
}
return shouldUpdate;
}

@Override
public EmployeeRow upsert(EmployeeRow unsaved, Connection c) {
map.put(unsaved.id(), unsaved);
return unsaved;
}

@Override
public List<EmployeeRow> upsertBatch(Iterator<EmployeeRow> unsaved, Connection c) {
var result = new ArrayList<EmployeeRow>();
while (unsaved.hasNext()) {
var row = unsaved.next();
map.put(row.id(), row);
result.add(row);
}
;
return result;
}

/** NOTE: this functionality is not safe if you use auto-commit mode! it runs 3 SQL statements */
@Override
public Integer upsertStreaming(Iterator<EmployeeRow> unsaved, Integer batchSize, Connection c) {
var count = 0;
while (unsaved.hasNext()) {
var row = unsaved.next();
map.put(row.id(), row);
count = count + 1;
}
;
return count;
}
}

DSL Support​

These mocks work with the DSL, which lets you describe semi-complex joins, updates, where predicates, string operations and so on in your code, and test it in-memory!

Note​

Typr guarantees schema correctness, but you can still break constraints. Or your tests need more advanced database functionality.

Stubs are obviously not a full replacement, but if they can be used for some non-zero percentage of your tests it's still very beneficial!