Lab 5.2: Add a Missing Unit Test
Background
OpenSearch has thousands of classes, and coverage is uneven. Some classes — especially small value
objects, Writeable request/response types, and helper utilities added in a hurry — ship with no
sibling *Tests class. These are the cheapest, highest-value contributions a new contributor can make:
a focused unit test that nails down equals/hashCode, exercises edge cases, and round-trips wire
serialization. Maintainers merge these readily because they increase the safety net without touching
production behavior.
This lab teaches you to find an under-tested class with grep, then write a proper
OpenSearchTestCase for it — using the framework's randomization helpers, exception assertions, and the
serialization round-trip harness AbstractWireSerializingTestCase. You learn what separates a good
assertion (one that would fail if the code were wrong) from a useless one (one that passes no matter
what).
Note: Read Level 5 index first if you have not. This lab assumes you know the test taxonomy table, the meaning of
-Dtests.seed, and therandom*helpers.
Why This Lab Matters for Contributors
- "Where's the test?" is the first question on nearly every PR. A test-only PR is the fastest way to
build trust and get your name in
CHANGELOG.md. - Writing a serialization round-trip teaches you the wire protocol (
StreamInput/StreamOutput,Writeable,NamedWriteableRegistry) that underpins every transport message — see the serialization & BWC deep dive. - Randomized tests find bugs example-based tests miss. Learning to let the framework pick inputs is a permanent upgrade to how you test.
- A wrong
equals/hashCodeis a real, shippable bug class (deduplication, cluster-state diffing, set membership). A contract test catches it.
Prerequisites
- A clean build (
./gradlew :server:compileJavasucceeds). - You can run a scoped test:
./gradlew :server:test --tests "*ClusterStateTests". - An IDE that resolves
org.opensearch.*imports (IntelliJ recommended;./gradlew ideaif needed).
java -version # JDK 21 baseline for 3.x
./gradlew :server:compileTestJava -q # test sources compile
Step-by-Step Tasks
Step 1: Find an under-tested class
The convention is that Foo.java is tested by FooTests.java in the mirrored src/test package. A
class with no sibling *Tests is a candidate. Find one:
# List main classes whose name has no matching *Tests.java anywhere in the repo.
cd server/src/main/java
for f in $(find org/opensearch -name '*.java' | sed 's#.*/##; s/\.java$//'); do
if ! grep -rql "class ${f}Tests" ../../../../server/src/test/java 2>/dev/null; then
echo "$f"
fi
done | sort | head -50
cd - >/dev/null
That is a blunt instrument (it ignores inner classes and abstract types), so narrow to good targets.
The best candidates are small, self-contained value types — ideally Writeable and/or
ToXContent, with an equals/hashCode:
# Writeable value types under server, then check which lack a *Tests sibling.
grep -rln "implements Writeable\|implements Writeable.Reader\|extends TransportResponse\|extends ActionRequest" \
server/src/main/java/org/opensearch | while read -r f; do
base=$(basename "$f" .java)
if ! find server/src/test -name "${base}Tests.java" | grep -q .; then
echo "UNTESTED $f"
fi
done | head -40
Note: Exact hits vary by branch — that is expected and fine. Pick one small class you can fully understand in 20 minutes. Avoid anything that needs a live node, a
ThreadPool, or anIndexShardto construct; those belong in heavier tests. You want a pure value object.
Record your choice. For the rest of this lab the running example is a fictional but representative value type:
public final class ShardCounts implements Writeable {
private final int total;
private final int active;
private final String indexName;
public ShardCounts(int total, int active, String indexName) { /* validates total >= active >= 0 */ }
public ShardCounts(StreamInput in) throws IOException { /* reads in field order */ }
@Override public void writeTo(StreamOutput out) throws IOException { /* writes in field order */ }
// getters, equals, hashCode, toString
}
Map every method on your real class to a test below.
Step 2: Read the class before you test it
You cannot write a good assertion for code you have not read. For your chosen class, answer:
CLASS=ShardCounts # <- your class
FILE=$(find server/src/main/java -name "${CLASS}.java")
echo "$FILE"
grep -n "public ${CLASS}\|StreamInput\|writeTo\|equals\|hashCode\|throw new\|Objects.requireNonNull\|assert " "$FILE"
Specifically note:
- Invariants the constructor enforces (
if (...) throw new IllegalArgumentException(...)). Each one is anassertThrowstest. - Field write order in
writeTovs read order in theStreamInputconstructor. They must match; a round-trip test proves it. - Whether
equals/hashCodeuse all fields or a subset (a subset is sometimes a bug). - Whether it's also
ToXContentObject— if so, preferAbstractSerializingTestCase(wire and XContent) overAbstractWireSerializingTestCase(wire only).
Step 3: Create the test file in the mirrored package
CLASS=ShardCounts
PKG_DIR=$(dirname "$(find server/src/main/java -name "${CLASS}.java")" | sed 's#src/main#src/test#')
mkdir -p "$PKG_DIR"
echo "Create: $PKG_DIR/${CLASS}Tests.java"
Every test file carries the SPDX header (precommit's licenseHeaders check fails without it):
/*
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*/
Step 4: Write the construction, validation, and accessor tests
Start with OpenSearchTestCase (the cheapest base). Use the random* helpers so each run exercises
different inputs — and centralize construction in a randomShardCounts() factory you will reuse:
package org.opensearch.cluster.metadata; // mirror the class's package
import org.opensearch.test.OpenSearchTestCase;
public class ShardCountsTests extends OpenSearchTestCase {
/** One place that builds a valid random instance — reused by every test. */
static ShardCounts randomShardCounts() {
int total = randomIntBetween(0, 50);
int active = randomIntBetween(0, total); // respect the invariant active <= total
String idx = randomAlphaOfLength(randomIntBetween(1, 20));
return new ShardCounts(total, active, idx);
}
public void testAccessorsReflectConstructorArgs() {
int total = randomIntBetween(0, 50);
int active = randomIntBetween(0, total);
String idx = randomAlphaOfLength(randomIntBetween(1, 20));
ShardCounts counts = new ShardCounts(total, active, idx);
assertEquals(total, counts.getTotal());
assertEquals(active, counts.getActive());
assertEquals(idx, counts.getIndexName());
}
public void testRejectsNegativeTotal() {
int badTotal = -randomIntBetween(1, 10); // strictly negative
IllegalArgumentException e = expectThrows(
IllegalArgumentException.class,
() -> new ShardCounts(badTotal, 0, "idx")
);
assertThat(e.getMessage(), containsString("total")); // assert the message, not just the type
}
public void testRejectsActiveGreaterThanTotal() {
int total = randomIntBetween(0, 20);
int active = total + randomIntBetween(1, 5); // violates active <= total
expectThrows(IllegalArgumentException.class, () -> new ShardCounts(total, active, "idx"));
}
public void testRejectsNullIndexName() {
expectThrows(NullPointerException.class, () -> new ShardCounts(1, 1, null));
}
}
Note:
expectThrowsis OpenSearch's preferred form (it returns the exception so you can assert on the message);assertThrowsfrom JUnit works too. Always assert on something inside the exception (message substring or a field). A bare "an exception was thrown" test passes even when the wrong exception is thrown for the wrong reason.
Step 5: Add the equals/hashCode contract test
OpenSearch ships a ready-made contract checker, EqualsHashCodeTestUtils, that verifies reflexivity,
symmetry, the equals↔hashCode agreement, and that a mutated copy is unequal:
import org.opensearch.test.EqualsHashCodeTestUtils;
public void testEqualsAndHashCode() {
EqualsHashCodeTestUtils.checkEqualsAndHashCode(
randomShardCounts(), // a random instance
original -> new ShardCounts( // copy: must be equal & same hash
original.getTotal(), original.getActive(), original.getIndexName()),
original -> { // mutate: must be NOT equal
switch (randomIntBetween(0, 2)) {
case 0: return new ShardCounts(
original.getTotal() + 1, original.getActive(), original.getIndexName());
case 1: int a = randomValueOtherThan(
original.getActive(), () -> randomIntBetween(0, original.getTotal()));
return new ShardCounts(original.getTotal(), a, original.getIndexName());
default: return new ShardCounts(
original.getTotal(), original.getActive(),
original.getIndexName() + randomAlphaOfLength(1));
}
});
}
The mutator is the part that catches bugs: if equals ignores indexName, the case default branch
produces an object that is "equal" but shouldn't be, and the test fails. randomValueOtherThan
guarantees the mutated field actually changes.
Step 6: Add the wire serialization round-trip
For a Writeable, the cleanest approach is to convert the test to extend
AbstractWireSerializingTestCase<T>, which gives you a full round-trip harness (write → read →
assert equal) that also runs the BWC matrix. You implement three methods:
package org.opensearch.cluster.metadata;
import org.opensearch.common.io.stream.Writeable;
import org.opensearch.test.AbstractWireSerializingTestCase;
public class ShardCountsTests extends AbstractWireSerializingTestCase<ShardCounts> {
@Override
protected ShardCounts createTestInstance() {
int total = randomIntBetween(0, 50);
int active = randomIntBetween(0, total);
return new ShardCounts(total, active, randomAlphaOfLength(randomIntBetween(1, 20)));
}
@Override
protected Writeable.Reader<ShardCounts> instanceReader() {
return ShardCounts::new; // the StreamInput constructor
}
@Override
protected ShardCounts mutateInstance(ShardCounts instance) {
// Return an instance guaranteed NOT equal to `instance` (drives the inequality checks).
int total = instance.getTotal() + randomIntBetween(1, 5);
return new ShardCounts(total, instance.getActive(), instance.getIndexName());
}
// ... plus the validation / accessor tests from Step 4 (they coexist in the same class) ...
}
AbstractWireSerializingTestCase provides testSerialization() (write to a BytesStreamOutput, read
back, assert equal — for free) and, via mutateInstance, checks that unequal instances stay unequal
across the wire. If your writeTo and StreamInput constructor disagree on field order, this test
fails immediately — which is exactly the bug you want it to find.
Note: If your type is also
ToXContentObject, extendAbstractSerializingTestCase<T>instead and additionally implementdoParseInstance(XContentParser); you then get the JSON round-trip (toXContent→fromXContent) for free as well.
Step 7: Run it (and reproduce a failure)
# Whole class
./gradlew :server:test --tests "org.opensearch.cluster.metadata.ShardCountsTests"
# One method while iterating
./gradlew :server:test --tests "*ShardCountsTests.testEqualsAndHashCode"
# Hammer it for flakiness across many random inputs
./gradlew :server:test --tests "*ShardCountsTests" -Dtests.iters=100
If a run fails, copy the printed reproduce line verbatim — its -Dtests.seed=... pins the exact
random inputs:
REPRODUCE WITH: ./gradlew ':server:test' --tests "org.opensearch.cluster.metadata.ShardCountsTests.testSerialization" \
-Dtests.seed=1A2B3C4D5E6F7A8B -Dtests.locale=de-DE -Dtests.timezone=Asia/Tokyo
A failing serialization round-trip here usually means a real ordering or version-gating bug in the class — investigate the production code before "fixing" the test.
Step 8: Pass precommit and add a CHANGELOG entry
./gradlew spotlessApply # auto-format
./gradlew :server:precommit # headers, forbidden-APIs, checkstyle, loggerUsageCheck
Add one line under ## [Unreleased ...] in CHANGELOG.md:
### Added
- Add unit tests for `ShardCounts` (equals/hashCode contract + wire serialization round-trip) ([#NNNNN](https://github.com/opensearch-project/OpenSearch/pull/NNNNN))
Commit with DCO sign-off:
git checkout -b test/shardcounts-unit-tests
git add server/src/test/java CHANGELOG.md
git commit -s -m "Add unit tests for ShardCounts"
What Makes a Good Assertion
This is the heart of the lab. An assertion is good only if it would fail when the code is wrong.
| Weak assertion | Why it's weak | Strong replacement |
|---|---|---|
assertNotNull(counts) after construction | A constructor that returns garbage still passes | Assert each accessor equals its constructor arg |
expectThrows(Exception.class, ...) | Passes for any exception, including an NPE bug | Pin the exact type and assert a message substring |
assertTrue(a.equals(b)) with a == b | Reflexivity is trivially true; tests nothing | Compare a fresh copy and a mutated instance |
assertEquals(json, obj.toString()) | Brittle on whitespace; couples to formatting | Round-trip via the XContent harness, compare objects |
| Asserting only the happy path | Bugs live in edge cases | Cover boundaries (0, max, empty string) and invalid input |
| Hard-coded inputs only | Misses input-dependent bugs | Drive with random*; let the seed widen coverage |
Warning: A test that always passes is worse than no test — it gives false confidence and future contributors trust it. Before you commit, break the production code on purpose (e.g. drop a field from
writeTo, or makeequalsignoreindexName) and confirm your test goes red. If it stays green, your assertions are too weak. Then revert the sabotage.
Implementation Requirements / Deliverables
-
A
*Testsclass in the mirrored test package, with the SPDX header. -
Construction + accessor tests driven by
random*helpers. -
At least one
expectThrowstest per constructor invariant, asserting the message. -
An
equals/hashCodecontract test (EqualsHashCodeTestUtils.checkEqualsAndHashCode) with a mutator that changes a real field. -
A wire round-trip via
AbstractWireSerializingTestCase(or the XContent variant if applicable), withcreateTestInstance,instanceReader,mutateInstance. -
./gradlew :server:precommitpasses;spotlessApplyapplied; aCHANGELOG.mdentry added. - Proof the tests have teeth: a note describing the sabotage you applied and that the test failed.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
error: cannot find symbol randomAlphaOfLength | Not extending an OpenSearch base test class | Extend OpenSearchTestCase / AbstractWireSerializingTestCase |
licenseHeaders precommit failure | Missing SPDX header | Paste the Apache-2.0 header block at the top |
| Test "passes" but you suspect it shouldn't | Assertions too weak | Sabotage the production code; confirm red; revert |
NamedWriteableRegistry / "unknown writeable" in round-trip | Your type is a NamedWriteable | Override getNamedWriteableRegistry() to register the entry |
| Round-trip fails only on some seeds | Field order mismatch or version gating | Diff writeTo against the StreamInput ctor field-by-field |
Class not found by Gradle / test never runs | Wrong source set or name | File must be *Tests.java under src/test, in the mirrored package |
Expected Output
> Task :server:test
org.opensearch.cluster.metadata.ShardCountsTests > testSerialization PASSED
org.opensearch.cluster.metadata.ShardCountsTests > testEqualsAndHashCode PASSED
org.opensearch.cluster.metadata.ShardCountsTests > testRejectsNegativeTotal PASSED
org.opensearch.cluster.metadata.ShardCountsTests > testRejectsActiveGreaterThanTotal PASSED
org.opensearch.cluster.metadata.ShardCountsTests > testAccessorsReflectConstructorArgs PASSED
BUILD SUCCESSFUL in 41s
HTML report (open after a run):
open server/build/reports/tests/test/index.html # macOS
Stretch Goals
- Add the XContent round-trip. If your type is
ToXContentObject, switch toAbstractSerializingTestCase, implementdoParseInstance, and confirmtoXContent→fromXContentis lossless. AddassertToXContentEquivalentfor a hand-written JSON sample. - Add a BWC serialization assertion. Override the version range so the harness serializes against
older
Versions. If the class gates a field behindout.getVersion().onOrAfter(...), verify the round-trip across that boundary. See the serialization & BWC deep dive. - Find a second untested class and repeat — bundle several into one "increase test coverage" PR; maintainers love these.
- Propagate a real edge case. Use a real
Version.CURRENTcorner (empty collection, max int) that the original author plausibly forgot and confirm behavior is sane.
Validation / Self-check
Answer these before you call the lab done:
- Show the
grepyou used to find an untested class. Why are smallWriteablevalue types the best targets, and what classes did you deliberately avoid (and why)? - What does
AbstractWireSerializingTestCasegive you for free, and which three methods did you implement? What would break if yourwriteToandStreamInputconstructor disagreed on field order? - Why is
expectThrows(IllegalArgumentException.class, ...)not enough on its own? What did you add? - In
EqualsHashCodeTestUtils.checkEqualsAndHashCode, what is the mutator for, and what bug does it catch that a "copy is equal" check cannot? - Describe the sabotage you applied to prove your test has teeth, and which assertion caught it.
- When would you reach for
AbstractSerializingTestCaseinstead ofAbstractWireSerializingTestCase? - Why must this stay an
OpenSearchTestCase/serialization test rather than anOpenSearchIntegTestCase? (Hint: cost vs. what the change actually touches.)
When you can find a target, write assertions that fail on broken code, and round-trip a Writeable
without a live node, move on to Lab 5.3: Build It — A Multi-Node Integration Test.