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.
Coding Exercises
These build on the *Tests class you wrote. Every one is a unit test under :server:test — no live
node. Pin the class/methods you touch with rg (e.g.
rg "checkEqualsAndHashCode|randomValueOtherThan|copyWriteable" test/framework/src/main/java) rather
than trusting any line number.
- (warm-up) One
expectThrowsper invariant, asserting the message. For your real target class, enumerate everythrow new IllegalArgumentException/NullPointerExceptionin its constructor (find them:rg "throw new" $(rg -l "class YourClass " server/src/main/java)). Write oneexpectThrowstest per invariant that pins the exact exception type and asserts acontainsString(...)on the message. A bare type check is not enough — prove it by changing oneIllegalArgumentExceptionto a different message and watching only that test fail. - (warm-up) Table-driven boundary test. Replace scattered example tests with one table-driven
method: build a small array of
{total, active, expectValid}rows including the boundaries (0,0,total,total,total,total+1), loop over them, and assert valid rows construct while invalid rowsexpectThrows. This is the cheap way to nail edge behavior the original author likely skipped. - (core) Convert to
AbstractWireSerializingTestCase<T>and add the round-trip. Migrate your class to extend the harness (Step 6), implementingcreateTestInstance,instanceReader, andmutateInstance. ThemutateInstancemust return a guaranteed-unequal instance — userandomValueOtherThanfor any field with a small range. ConfirmtestSerializationruns for free and is green. - (core) The missing-coverage test that actually catches a bug. Pick a method on your class with
no assertion exercising it (often
toString, a derived getter, or anequalsthat skips a field). Write the test, then sabotage the production code to break exactly that path (drop a field fromwriteTo, makeequalsignoreindexName). Confirm your new test goes red, record which assertion caught it, then revert. A coverage test that stays green under sabotage is theater. - (core) XContent round-trip (if applicable). If your type is
ToXContentObject, switch toAbstractSerializingTestCase<T>, implementdoParseInstance(XContentParser), and asserttoXContent→fromXContentis lossless. Add oneassertToXContentEquivalentagainst a hand-written JSON sample to pin the exact field names. Find the base class withrg "class AbstractSerializingTestCase" test/framework/src/main/java. - (advanced challenge) BWC serialization across a version boundary, on a class you make
version-gated. If your target class doesn't already gate a field by version, add a throwaway one
for the drill: write a new field only
out.getVersion().onOrAfter(Version.V_X_Y_Z)and read it only on the same condition (find the pattern:rg "out.getVersion\(\).onOrAfter" server/src/main/java | head). Then overridebwcVersions/the serialization version range in your test so the harness round-trips against an olderVersion, and assert the field defaults sensibly when absent. Run-Dtests.iters=100. The payoff: you have proven, with a test, that a serialization change is backward compatible — the single most common reason a transport PR is rejected. Cross-reference the serialization & BWC deep dive, then revert the throwaway field.
Issues to Practice On
Test-only and small value-type fixes are the friendliest on-ramp in the whole project; maintainers
merge them readily. Labels drift — list them first with
gh label list --repo opensearch-project/OpenSearch and pick.
# Coverage gaps and small correctness fixes (labels move; confirm on the tracker).
gh issue list --repo opensearch-project/OpenSearch --label "good first issue" --state open
gh issue list --repo opensearch-project/OpenSearch --label "bug" --state open
gh issue list --repo opensearch-project/OpenSearch --search "missing test in:title is:open"
gh issue list --repo opensearch-project/OpenSearch --search "equals hashCode in:title,body is:open"
gh issue list --repo opensearch-project/OpenSearch --label "untriaged" --search "serialization"
Representative patterns:
- "Class X has no unit test." Find an untested
Writeable/ToXContentvalue type (the Step 1grep), write the contract + round-trip test, and PR it as a "increase test coverage" change. These bundle well: several classes in one PR. - "Serialization breaks across versions / BWC bug." A field was added without a version gate, or the
writeToandStreamInputctor disagree on order. Reproduce with the wire round-trip harness across aVersionboundary, fix the gate, and add the BWC assertion that would have caught it (Exercise 6).
Planted bug: In the
writeToof your target class, swap the order of two field writes (or delete one write entirely). Run./gradlew :server:test --tests "*YourClassTests.testSerialization"— the round-trip must go red (read order no longer matches write order). If it doesn't, yourmutateInstance/createTestInstancearen't exercising that field; strengthen them until the bug is caught, then revert the swap. This is the exact failure mode the harness exists to catch.
Etiquette: Claim before you work, reproduce first, and every PR ships a test +
CHANGELOG.mdentry + DCOSigned-off-by(git commit -s). See community interaction and the level-2 PR lab.
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.