This guide covers the testing frameworks, conventions, and best practices for testing Concourse.
- Overview
- Test Frameworks
- Writing Unit Tests
- Writing Integration Tests
- End-to-End Tests
- Running Tests
- Bug Reproduction Tests
- Test Utilities
Concourse uses multiple testing frameworks to ensure quality at different levels:
| Framework | Purpose | Location |
|---|---|---|
concourse-unit-test-core |
Base classes for unit tests | Unit tests in each module |
concourse-integration-tests |
Tests against in-process server | concourse-integration-tests/ |
concourse-ete-test-core |
End-to-end testing framework | ETE tests |
concourse-ete-tests |
Cross-version and performance tests | concourse-ete-tests/ |
Provides base classes and utilities for unit testing:
ConcourseBaseTest- Base class for all unit testsVariables- Utility for tracking test state- Test data generators
Integration tests that run against the actual storage engine:
ConcourseIntegrationTest- Base class with client/server setup- Tests run against an in-process Concourse instance
- Full API testing including transactions
End-to-end testing framework for realistic scenarios:
ClientServerTest- Tests against external server processCrossVersionTest- Tests across multiple Concourse versions- Automatic server lifecycle management
Actual end-to-end tests including:
- Cross-version compatibility tests
- Performance benchmarks
- Upgrade scenario tests
All unit tests should extend ConcourseBaseTest:
import com.cinchapi.concourse.test.ConcourseBaseTest;
import org.junit.Test;
public class MyFeatureTest extends ConcourseBaseTest {
@Test
public void testMyFeature() {
// Test implementation
}
}The Variables class tracks test state and dumps it on failure:
import com.cinchapi.concourse.test.Variables;
@Test
public void testAddWithRandomData() {
// Register variables for debugging
String key = Variables.register("key", TestData.getString());
Object value = Variables.register("value", TestData.getObject());
long record = Variables.register("record", TestData.getLong());
// Perform test
boolean result = store.add(key, value, record);
// Assert
assertTrue(result);
}On test failure, output looks like:
TEST FAILURE in testAddWithRandomData: expected:<true> but was:<false>
---
key = myTestKey (java.lang.String)
value = 42 (java.lang.Integer)
record = 12345 (java.lang.Long)
Use descriptive names that explain what is being tested:
// Good: Clear what is being tested and expected outcome
@Test
public void testAddReturnsFalseWhenValueAlreadyExists() { }
@Test
public void testFindReturnsEmptySetWhenNoCriteriaMatch() { }
@Test
public void testTransactionAbortsOnConflict() { }
// Avoid: Vague or unclear names
@Test
public void testAdd() { }
@Test
public void test1() { }Override the provided methods for setup/teardown:
public class MyTest extends ConcourseBaseTest {
private MyComponent component;
@Override
protected void beforeEachTest() {
component = new MyComponent();
component.initialize();
}
@Override
protected void afterEachTest() {
component.cleanup();
}
@Test
public void testSomething() {
// component is ready to use
}
}Integration tests extend ConcourseIntegrationTest:
import com.cinchapi.concourse.test.ConcourseIntegrationTest;
import org.junit.Test;
public class MyIntegrationTest extends ConcourseIntegrationTest {
@Test
public void testFeatureEndToEnd() {
// 'client' variable is automatically available
client.add("key", "value", 1);
Object result = client.get("key", 1);
assertEquals("value", result);
}
}Integration tests have access to:
client- ConnectedConcourseclient instanceserver-ManagedConcourseServerfor server control
@Test
public void testServerRestart() {
// Add data
client.add("key", "value", 1);
// Restart server
server.stop();
server.start();
// Reconnect
client = server.connect();
// Verify data persisted
assertEquals("value", client.get("key", 1));
}@Test
public void testTransactionIsolation() {
// Start transaction
client.stage();
try {
client.add("key", "value", 1);
client.set("status", "pending", 1);
// Commit
assertTrue(client.commit());
}
catch (TransactionException e) {
client.abort();
fail("Transaction should not fail");
}
}Extend ClientServerTest for tests against a specific version:
import com.cinchapi.concourse.test.ClientServerTest;
public class MyEteTest extends ClientServerTest {
@Override
protected String getServerVersion() {
// Return version number or path to installer
return "0.12.0";
// Or: return "/path/to/concourse-server-0.12.0.bin";
}
@Test
public void testFeature() {
// 'client' is connected to external server
client.add("key", "value", 1);
// Server runs in separate JVM
assertNotNull(client.get("key", 1));
}
}Test across multiple versions using CrossVersionTest:
import com.cinchapi.concourse.test.CrossVersionTest;
import com.cinchapi.concourse.test.runners.CrossVersionTestRunner.Versions;
@Versions({"0.10.0", "0.11.0", "0.12.0"})
public class MyCrossVersionTest extends CrossVersionTest {
@Test
public void testBackwardCompatibility() {
// This test runs against each specified version
client.add("key", "value", 1);
assertEquals("value", client.get("key", 1));
}
}public class MyPerformanceTest extends ClientServerTest {
@Override
protected String getServerVersion() {
return "0.12.0";
}
@Test
public void testWriteThroughput() {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
client.add("key" + i, "value", i);
}
long duration = System.currentTimeMillis() - start;
System.out.println("Throughput: " + (10000.0 / duration * 1000) + " ops/sec");
}
}./gradlew test./gradlew :concourse-server:test
./gradlew :concourse-integration-tests:test
./gradlew :concourse-ete-tests:test./gradlew :concourse-server:test --tests "*.EngineTest"
./gradlew :concourse-integration-tests:test --tests "*.AddTest"./gradlew :concourse-server:test --tests "*.EngineTest.testAddBasic"./gradlew test --info
./gradlew test --debugRun predefined test suites:
# Integration test suite
./gradlew :concourse-integration-tests:test --tests "*.IntegrationTestSuite"
# Performance test suite
./gradlew :concourse-integration-tests:test --tests "*.PerformanceTestSuite"
# Bug reproduction suite
./gradlew :concourse-integration-tests:test --tests "*.BugReproSuite"# Run tests in parallel
./gradlew test --parallel
# Control max workers
./gradlew test --max-workers=4Bug reproduction tests live in the bugrepro/ package and follow a naming convention:
- GitHub issues:
GH{number}.java(e.g.,GH123.java) - JIRA issues:
CON{number}.java(e.g.,CON456.java)
package com.cinchapi.concourse.bugrepro;
import com.cinchapi.concourse.test.ConcourseIntegrationTest;
import org.junit.Test;
/**
* Reproduction test for GitHub issue #123.
*
* @see <a href="https://github.com/cinchapi/concourse/issues/123">GH-123</a>
*/
public class GH123 extends ConcourseIntegrationTest {
@Test
public void testReproduction() {
// Steps to reproduce the bug
// After fix, this test should pass
}
}- When fixing a reported bug
- When a test reveals unexpected behavior
- When regression testing is needed
Generate random test data:
import com.cinchapi.concourse.util.TestData;
String key = TestData.getString();
long record = TestData.getLong();
int number = TestData.getInt();
Object value = TestData.getObject();import com.cinchapi.concourse.Timestamp;
// Create timestamps for testing
Timestamp now = Timestamp.now();
Timestamp past = Timestamp.fromMicros(now.getMicros() - 1000000);Use JUnit assertions with meaningful messages:
import static org.junit.Assert.*;
// Good: Descriptive message on failure
assertEquals("Value should be stored", expected, actual);
assertTrue("Add should succeed for new value", result);
assertFalse("Add should fail for duplicate", result);
// Checking collections
assertNotNull("Result should not be null", result);
assertFalse("Result should not be empty", result.isEmpty());
assertEquals("Should return exactly 3 records", 3, result.size());import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@Test
public void testConcurrentWrites() throws Exception {
int numThreads = 10;
int writesPerThread = 100;
CountDownLatch latch = new CountDownLatch(numThreads);
ExecutorService executor = Executors.newFixedThreadPool(numThreads);
for (int t = 0; t < numThreads; t++) {
final int threadId = t;
executor.submit(() -> {
try {
for (int i = 0; i < writesPerThread; i++) {
client.add("key", threadId * 1000 + i, 1);
}
}
finally {
latch.countDown();
}
});
}
latch.await();
executor.shutdown();
// Verify all writes succeeded
Set<Object> values = client.select("key", 1).get("key");
assertEquals(numThreads * writesPerThread, values.size());
}- Write tests before or alongside code changes
- Use descriptive test method names
- Register variables with
Variables.register()for debugging - Clean up resources in
afterEachTest() - Test edge cases and error conditions
- Skip tests to speed up development
- Use
@Ignorewithout a tracking issue - Write tests that depend on execution order
- Leave debugging output in committed tests
- Test implementation details instead of behavior
- Developer Guide - Development workflow
- Contributing Guidelines - How to contribute
- Architecture Overview - System design