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 the random* 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/hashCode is a real, shippable bug class (deduplication, cluster-state diffing, set membership). A contract test catches it.

Prerequisites

  • A clean build (./gradlew :server:compileJava succeeds).
  • You can run a scoped test: ./gradlew :server:test --tests "*ClusterStateTests".
  • An IDE that resolves org.opensearch.* imports (IntelliJ recommended; ./gradlew idea if 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 an IndexShard to 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 an assertThrows test.
  • Field write order in writeTo vs read order in the StreamInput constructor. They must match; a round-trip test proves it.
  • Whether equals/hashCode use all fields or a subset (a subset is sometimes a bug).
  • Whether it's also ToXContentObject — if so, prefer AbstractSerializingTestCase (wire and XContent) over AbstractWireSerializingTestCase (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: expectThrows is OpenSearch's preferred form (it returns the exception so you can assert on the message); assertThrows from 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 equalshashCode 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, extend AbstractSerializingTestCase<T> instead and additionally implement doParseInstance(XContentParser); you then get the JSON round-trip (toXContentfromXContent) 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 assertionWhy it's weakStrong replacement
assertNotNull(counts) after constructionA constructor that returns garbage still passesAssert each accessor equals its constructor arg
expectThrows(Exception.class, ...)Passes for any exception, including an NPE bugPin the exact type and assert a message substring
assertTrue(a.equals(b)) with a == bReflexivity is trivially true; tests nothingCompare a fresh copy and a mutated instance
assertEquals(json, obj.toString())Brittle on whitespace; couples to formattingRound-trip via the XContent harness, compare objects
Asserting only the happy pathBugs live in edge casesCover boundaries (0, max, empty string) and invalid input
Hard-coded inputs onlyMisses input-dependent bugsDrive 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 make equals ignore indexName) and confirm your test goes red. If it stays green, your assertions are too weak. Then revert the sabotage.


Implementation Requirements / Deliverables

  • A *Tests class in the mirrored test package, with the SPDX header.
  • Construction + accessor tests driven by random* helpers.
  • At least one expectThrows test per constructor invariant, asserting the message.
  • An equals/hashCode contract test (EqualsHashCodeTestUtils.checkEqualsAndHashCode) with a mutator that changes a real field.
  • A wire round-trip via AbstractWireSerializingTestCase (or the XContent variant if applicable), with createTestInstance, instanceReader, mutateInstance.
  • ./gradlew :server:precommit passes; spotlessApply applied; a CHANGELOG.md entry added.
  • Proof the tests have teeth: a note describing the sabotage you applied and that the test failed.

Troubleshooting

SymptomLikely causeFix
error: cannot find symbol randomAlphaOfLengthNot extending an OpenSearch base test classExtend OpenSearchTestCase / AbstractWireSerializingTestCase
licenseHeaders precommit failureMissing SPDX headerPaste the Apache-2.0 header block at the top
Test "passes" but you suspect it shouldn'tAssertions too weakSabotage the production code; confirm red; revert
NamedWriteableRegistry / "unknown writeable" in round-tripYour type is a NamedWriteableOverride getNamedWriteableRegistry() to register the entry
Round-trip fails only on some seedsField order mismatch or version gatingDiff writeTo against the StreamInput ctor field-by-field
Class not found by Gradle / test never runsWrong source set or nameFile 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

  1. Add the XContent round-trip. If your type is ToXContentObject, switch to AbstractSerializingTestCase, implement doParseInstance, and confirm toXContentfromXContent is lossless. Add assertToXContentEquivalent for a hand-written JSON sample.
  2. Add a BWC serialization assertion. Override the version range so the harness serializes against older Versions. If the class gates a field behind out.getVersion().onOrAfter(...), verify the round-trip across that boundary. See the serialization & BWC deep dive.
  3. Find a second untested class and repeat — bundle several into one "increase test coverage" PR; maintainers love these.
  4. Propagate a real edge case. Use a real Version.CURRENT corner (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:

  1. Show the grep you used to find an untested class. Why are small Writeable value types the best targets, and what classes did you deliberately avoid (and why)?
  2. What does AbstractWireSerializingTestCase give you for free, and which three methods did you implement? What would break if your writeTo and StreamInput constructor disagreed on field order?
  3. Why is expectThrows(IllegalArgumentException.class, ...) not enough on its own? What did you add?
  4. In EqualsHashCodeTestUtils.checkEqualsAndHashCode, what is the mutator for, and what bug does it catch that a "copy is equal" check cannot?
  5. Describe the sabotage you applied to prove your test has teeth, and which assertion caught it.
  6. When would you reach for AbstractSerializingTestCase instead of AbstractWireSerializingTestCase?
  7. Why must this stay an OpenSearchTestCase/serialization test rather than an OpenSearchIntegTestCase? (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.