.agents/skills/opik-backend/testing.md
import com.comet.opik.podam.PodamFactoryUtils;
private final PodamFactory podamFactory = PodamFactoryUtils.newPodamFactory();
@Test
void createUser() {
var request = podamFactory.manufacturePojo(UserCreateRequest.class)
.toBuilder()
.name("John Doe") // Override only what matters for test
.build();
// ...
}
Utility methods:
PodamFactoryUtils.manufacturePojoList(factory, Class) - Generate ListPodamFactoryUtils.manufacturePojoSet(factory, Class) - Generate Set// ✅ Happy path - same as method name
void createUser() { }
// ✅ Specific scenarios
void createUserWhenValidRequestReturnsUser() { }
void createUserWhenUserExistsReturnsConflict() { }
// ✅ Error paths
void createUserWhenInvalidEmailThrowsBadRequestException() { }
// ❌ Bad
void testCreateUser() { }
void should_create_user() { }
// ❌ BAD - Self-fulfilling prophecy (always passes!)
var actualValues = api.findSorted("name", "ASC");
var expectedValues = new ArrayList<>(actualValues);
expectedValues.sort(Comparator.naturalOrder());
assertThat(actualValues).isEqualTo(expectedValues);
// ✅ GOOD - Test against known data
var page = api.findSorted("name", "ASC");
assertThat(page.content())
.extracting(Entity::getName)
.containsExactly("Alice", "Bob", "Charlie");
// ✅ GOOD - Use AssertJ sorting assertions
assertThat(page.content())
.extracting(Entity::getName)
.isSorted();
// ✅ GOOD - Compare against independently sorted original
var expectedOrder = originalEntities.stream()
.sorted(comparator)
.map(Entity::getId)
.toList();
assertThat(actualOrder).isEqualTo(expectedOrder);
When you change query SQL that backs sorting, pagination, or field exclusion (e.g. the
two-phase page_ids/page_wide CTEs, deferred wide columns, EXCEPT/exclude_fields,
sort_needs_wide, dynamic sort_fields), the test MUST:
getAndAssertPage → TraceAssertions.assertTraces /
SpanAssertions.assertSpan) so every field is verified. ID-only assertions are too weak — they can't catch a row that
returns the right id with wrong/empty data.sort_fields, not only static columns — sort by a wide text column
(input/output/metadata) AND by a regular column, in both directions.EXCLUDE_FUNCTIONS.get(field) and pass the exclude set to getAndAssertPage.// ✅ GOOD - sort × exclude, full-page assertion (deferred-wide path)
var expected = traces.stream().sorted(comparator)
.map(t -> TraceAssertions.EXCLUDE_FUNCTIONS.get(excludeField).apply(t))
.toList();
getAndAssertPage(workspaceName, projectName, null, List.of(), traces, expected, List.of(),
apiKey, List.of(sortingField), Set.of(excludeField));
// ❌ BAD - Duplicate methods
void testSortByNameAsc() { }
void testSortByNameDesc() { }
void testSortByTypeAsc() { }
// ✅ GOOD - Single parameterized test
@ParameterizedTest(name = "Sort by {0} {1}")
@MethodSource("sortingTestCases")
void sortEntities(String field, String direction, Comparator<Entity> comparator) {
// Single test handles all scenarios
}
static Stream<Arguments> sortingTestCases() {
return Stream.of(
Arguments.of("name", "ASC", Comparator.comparing(Entity::getName)),
Arguments.of("name", "DESC", Comparator.comparing(Entity::getName).reversed())
);
}
// ❌ BAD - MySQL operations are synchronous
Awaitility.await().untilAsserted(() -> {
var page = client.findAll();
assertThat(page).hasSize(5);
});
// ✅ GOOD - Direct assertion for sync operations
var page = client.findAll();
assertThat(page).hasSize(5);
// ✅ GOOD - Awaitility only for truly async (Kafka, background jobs)
kafkaProducer.send(message);
Awaitility.await()
.atMost(5, TimeUnit.SECONDS)
.untilAsserted(() -> {
var processed = repository.find(message.getId());
assertThat(processed).isNotNull();
});
// Object equality when you have expected object
assertThat(actualUser).isEqualTo(expectedUser);
// Field assertions for specific checks
assertThat(result.getName()).isEqualTo("John Doe");
assertThat(result.getId()).isNotBlank();
// Exception assertions
assertThatThrownBy(() -> service.create(invalid))
.isInstanceOf(BadRequestException.class)
.hasMessageContaining("Name is required");
mvn ReactorEach resource test class that touches ClickHouse runs its own Liquibase migration against the
Testcontainers instance. Running two such classes in a single mvn invocation (e.g. spans + traces
together, or a wildcard that matches both) makes the second migration fail with
REPLICA_ALREADY_EXISTS (the replicated table from migration 000017 already exists) — a confusing
failure that looks like a product bug but is purely a test-harness collision.
When a change spans both spans and traces (the usual case for shared query SQL), run each class in a
separate mvn invocation:
# ✅ GOOD - separate invocations
mvn test -o -Dtest='FindSpansResourceTest$FindSpans#whenFilterSortExcludeAcrossPages*'
mvn test -o -Dtest='GetTracesByProjectResourceTest$FindTraces#getTracesByProject__whenFilterSortExcludeAcrossPages*'
# ❌ BAD - one reactor migrates ClickHouse twice -> REPLICA_ALREADY_EXISTS
mvn test -o -Dtest='FindSpansResourceTest,GetTracesByProjectResourceTest'
Surefire selectors for @Nested + parameterized tests: use OuterClass$NestedClass#methodPattern,
and prefer a *wildcard* over the exact (long) method name — exact long names silently match 0 tests.
Combine methods within a class with +, classes with , (but see the ClickHouse caveat above).