Contract Testing: Using Dredd for API Blueprint Validation in Java

Article

Dredd is a powerful API testing tool that validates your API implementation against its documentation written in API Blueprint or OpenAPI format. For Java teams, Dredd ensures that your API implementation always matches its specification, preventing API drift and maintaining contract consistency.


What is Dredd?

Dredd is a language-agnostic HTTP API testing framework that reads API description documents and validates your API implementation against them. It performs contract testing by making real HTTP requests to your API and comparing responses with the expected schema.

Key Benefits for Java Teams:

  • Contract Validation: Ensures implementation matches documentation
  • Prevents API Drift: Catches breaking changes early
  • CI/CD Integration: Easy to integrate into build pipelines
  • Language Agnostic: Works with any Java web framework
  • Automated Testing: Reduces manual API testing effort

Installation and Setup

1. Install Dredd

# Install Dredd globally
npm install -g dredd
# Or as development dependency
npm install --save-dev dredd
# Verify installation
dredd --version

2. Install API Blueprint Parser

# Install API Blueprint parser
npm install -g @apiblueprint/aglio
# Or install locally
npm install --save-dev @apiblueprint/aglio

3. Java Project Setup

Add testing dependencies to your Java project:

Maven:

<!-- pom.xml -->
<properties>
<maven-surefire-plugin.version>3.1.2</maven-surefire-plugin.version>
</properties>
<dependencies>
<!-- Spring Boot Test (if using Spring) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- JUnit 5 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<!-- REST Assured for API testing -->
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<!-- JSON Schema Validator -->
<dependency>
<groupId>com.networknt</groupId>
<artifactId>json-schema-validator</artifactId>
<version>1.0.72</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>${maven-surefire-plugin.version}</version>
</plugin>
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>1.12.1</version>
<executions>
<execution>
<id>install node and npm</id>
<goals>
<goal>install-node-and-npm</goal>
</goals>
<configuration>
<nodeVersion>v18.17.0</nodeVersion>
</configuration>
</execution>
<execution>
<id>npm install</id>
<goals>
<goal>npm</goal>
</goals>
<configuration>
<arguments>install</arguments>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>

Gradle:

// build.gradle
plugins {
id "com.github.node-gradle.node" version "3.5.1"
}
dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.rest-assured:rest-assured'
testImplementation 'com.networknt:json-schema-validator:1.0.72'
}
node {
version = '18.17.0'
download = true
}
task installDredd(type: NpmTask) {
args = ['install', 'dredd', '@apiblueprint/aglio']
}

API Blueprint Basics

1. Basic API Blueprint Structure

# apiary.apib
FORMAT: 1A
# My Java API
A simple API example for a Java Spring Boot application.
# Group Users
User related resources.
## User Collection [/api/users]
### List All Users [GET]
+ Response 200 (application/json)
[
{
"id": "1",
"name": "John Doe",
"email": "[email protected]",
"status": "active"
}
]
### Create a User [POST]
+ Request (application/json)
{
"name": "Jane Smith",
"email": "[email protected]"
}
+ Response 201 (application/json)
{
"id": "2",
"name": "Jane Smith",
"email": "[email protected]",
"status": "active",
"createdAt": "2023-11-15T10:30:00Z"
}
+ Response 400 (application/json)
{
"error": "Validation failed",
"message": "Email already exists"
}
## User [/api/users/{id}]
### Retrieve a User [GET]
+ Parameters
+ id (string) - User ID
+ Response 200 (application/json)
{
"id": "1",
"name": "John Doe",
"email": "[email protected]",
"status": "active",
"createdAt": "2023-11-15T10:30:00Z"
}
+ Response 404 (application/json)
{
"error": "User not found",
"message": "User with id 999 not found"
}
### Update a User [PUT]
+ Parameters
+ id (string) - User ID
+ Request (application/json)
{
"name": "John Updated",
"email": "[email protected]"
}
+ Response 200 (application/json)
{
"id": "1",
"name": "John Updated",
"email": "[email protected]",
"status": "active",
"updatedAt": "2023-11-15T11:30:00Z"
}
### Delete a User [DELETE]
+ Parameters
+ id (string) - User ID
+ Response 204
+ Response 404 (application/json)
{
"error": "User not found",
"message": "User with id 999 not found"
}

2. Advanced API Blueprint with Data Structures

# api-advanced.apib
FORMAT: 1A
# Advanced Java API
Advanced API example with data structures and authentication.
# Data Structures
## User (object)
+ id: 1 (string, required) - Unique identifier
+ name: John Doe (string, required) - User's full name
+ email: [email protected] (string, required) - User's email address
+ status: active (enum[string]) - User status
+ Members
+ `active`
+ `inactive`
+ `suspended`
+ createdAt: `2023-11-15T10:30:00Z` (string) - Creation timestamp
+ updatedAt: `2023-11-15T11:30:00Z` (string) - Last update timestamp
## Error (object)
+ error: Validation failed (string, required) - Error type
+ message: Email already exists (string, required) - Error description
+ details: (array[object], optional) - Validation errors
+ timestamp: `2023-11-15T10:30:00Z` (string) - When error occurred
## Pagination (object)
+ page: 0 (number) - Current page number
+ size: 20 (number) - Page size
+ totalElements: 100 (number) - Total elements
+ totalPages: 5 (number) - Total pages
# Group Authentication
Authentication endpoints.
## Login [/api/auth/login]
### Authenticate User [POST]
+ Request (application/json)
{
"username": "[email protected]",
"password": "secret123"
}
+ Response 200 (application/json)
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"type": "Bearer",
"expiresIn": 3600,
"user": (User)
}
+ Response 401 (application/json)
{
"error": "Authentication failed",
"message": "Invalid credentials"
}
# Group Products
Product management endpoints.
## Product Collection [/api/products]
### List Products [GET]
+ Parameters
+ page: 0 (number, optional) - Page number
+ size: 20 (number, optional) - Page size
+ category: electronics (string, optional) - Filter by category
+ Response 200 (application/json)
{
"content": [
{
"id": "prod-1",
"name": "Laptop",
"price": 999.99,
"category": "electronics",
"inStock": true
}
],
"pagination": (Pagination)
}
### Create Product [POST]
+ Headers
Authorization: Bearer {token}
+ Request (application/json)
{
"name": "Smartphone",
"price": 599.99,
"category": "electronics",
"description": "Latest smartphone model"
}
+ Response 201 (application/json)
{
"id": "prod-2",
"name": "Smartphone",
"price": 599.99,
"category": "electronics",
"description": "Latest smartphone model",
"inStock": true,
"createdAt": "2023-11-15T10:30:00Z"
}
+ Response 401 (application/json)
{
"error": "Unauthorized",
"message": "Authentication required"
}

Dredd Configuration

1. Basic Dredd Configuration

# dredd.yml
dry-run: null
hookfiles: 
- "dredd-hooks.js"
- "test/hooks/*_hooks.js"
language: nodejs
sandbox: false
server: null
server-wait: 3
init: false
custom: {}
names: false
only: []
output: []
header: []
sorted: false
user: null
inline-errors: false
details: false
method: []
color: true
level: info
timestamp: false
silent: false
path: []
hooks-worker-timeout: 5000
hooks-worker-connect-timeout: 1500
hooks-worker-connect-retry: 500
hooks-worker-after-connect-wait: 100
hooks-worker-term-timeout: 5000
hooks-worker-term-retry: 500
hooks-worker-handler-host: 127.0.0.1
hooks-worker-handler-port: 61321
config: ./dredd.yml
blueprint: apiary.apib
endpoint: 'http://localhost:8080'

2. Package.json Scripts

{
"name": "java-api-dredd",
"version": "1.0.0",
"scripts": {
"dredd": "dredd",
"test:api": "dredd apiary.apib http://localhost:8080 --hookfiles=dredd-hooks.js",
"test:api:ci": "dredd apiary.apib http://localhost:8080 --hookfiles=dredd-hooks.js --reporter=junit --output=reports/dredd.xml",
"test:api:html": "dredd apiary.apib http://localhost:8080 --hookfiles=dredd-hooks.js --reporter=html --output=reports/dredd.html",
"build:docs": "aglio -i apiary.apib -o docs/api.html"
},
"devDependencies": {
"dredd": "^15.0.0",
"@apiblueprint/aglio": "^4.3.0"
}
}

Java Application Setup

1. Spring Boot Controller

package com.example.api.controller;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api")
public class UserController {
@GetMapping("/users")
public ResponseEntity<List<UserResponse>> getUsers() {
List<UserResponse> users = List.of(
new UserResponse("1", "John Doe", "[email protected]", "active", 
Instant.parse("2023-11-15T10:30:00Z"), null),
new UserResponse("2", "Jane Smith", "[email protected]", "active",
Instant.parse("2023-11-15T10:35:00Z"), null)
);
return ResponseEntity.ok(users);
}
@PostMapping("/users")
public ResponseEntity<UserResponse> createUser(@Valid @RequestBody CreateUserRequest request) {
// Simulate user creation
UserResponse user = new UserResponse(
"3",
request.name(),
request.email(),
"active",
Instant.now(),
null
);
return ResponseEntity.status(201).body(user);
}
@GetMapping("/users/{id}")
public ResponseEntity<UserResponse> getUser(@PathVariable String id) {
if ("999".equals(id)) {
ErrorResponse error = new ErrorResponse(
"User not found", 
"User with id 999 not found",
Instant.now()
);
return ResponseEntity.status(404).body(null); // Dredd will validate the error
}
UserResponse user = new UserResponse(
id,
"John Doe",
"[email protected]",
"active",
Instant.parse("2023-11-15T10:30:00Z"),
null
);
return ResponseEntity.ok(user);
}
@PutMapping("/users/{id}")
public ResponseEntity<UserResponse> updateUser(
@PathVariable String id, 
@Valid @RequestBody UpdateUserRequest request) {
UserResponse user = new UserResponse(
id,
request.name(),
request.email(),
"active",
Instant.parse("2023-11-15T10:30:00Z"),
Instant.now()
);
return ResponseEntity.ok(user);
}
@DeleteMapping("/users/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable String id) {
if ("999".equals(id)) {
ErrorResponse error = new ErrorResponse(
"User not found",
"User with id 999 not found", 
Instant.now()
);
return ResponseEntity.status(404).build();
}
return ResponseEntity.noContent().build();
}
// DTOs
public record CreateUserRequest(String name, String email) {}
public record UpdateUserRequest(String name, String email) {}
public record UserResponse(String id, String name, String email, String status, 
Instant createdAt, Instant updatedAt) {}
public record ErrorResponse(String error, String message, Instant timestamp) {}
}

2. Spring Boot Test Configuration

package com.example.api;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.utility.DockerImageName;
@TestConfiguration(proxyBeanMethods = false)
public class TestApiApplication {
@Bean
@ServiceConnection
GenericContainer<?> redisContainer() {
return new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379);
}
public static void main(String[] args) {
SpringApplication.from(ApiApplication::main)
.with(TestApiApplication.class)
.run(args);
}
}

Dredd Hooks Implementation

1. JavaScript Hooks File

// dredd-hooks.js
const hooks = require('hooks');
const { exec } = require('child_process');
let serverProcess = null;
// Start Java application before tests
hooks.beforeAll((transactions, done) => {
console.log('Starting Java application...');
serverProcess = exec(
'mvn spring-boot:run -Dspring-boot.run.profiles=test',
{ cwd: process.cwd() },
(error) => {
if (error) {
console.error('Failed to start server:', error);
}
}
);
// Wait for server to start
setTimeout(() => {
console.log('Server should be ready...');
done();
}, 10000);
});
// Stop Java application after tests
hooks.afterAll((transactions, done) => {
console.log('Stopping Java application...');
if (serverProcess) {
serverProcess.kill();
}
done();
});
// Auth token storage
let authToken = '';
// Hook for authentication
hooks.before('Authentication > Login > Authenticate User', (transaction) => {
transaction.request.headers['Content-Type'] = 'application/json';
});
hooks.after('Authentication > Login > Authenticate User', (transaction) => {
if (transaction.real.statusCode === 200) {
const response = JSON.parse(transaction.real.body);
authToken = response.token;
console.log('Auth token stored:', authToken);
}
});
// Add auth token to protected requests
hooks.beforeEach((transaction) => {
const path = transaction.name;
// Add auth token to requests that need it
if (path.includes('Create Product') || path.includes('Update Product') || path.includes('Delete Product')) {
if (authToken) {
transaction.request.headers['Authorization'] = `Bearer ${authToken}`;
}
}
// Set common headers
transaction.request.headers['Content-Type'] = 'application/json';
transaction.request.headers['User-Agent'] = 'Dredd/1.0';
});
// Hook for specific test cases
hooks.before('Users > User Collection > Create a User', (transaction) => {
// Modify request body if needed
const body = JSON.parse(transaction.request.body);
body.email = `test-${Date.now()}@example.com`;
transaction.request.body = JSON.stringify(body);
});
hooks.before('Users > User > Retrieve a User', (transaction) => {
// Test both success and error cases
const fullPath = transaction.fullPath;
if (fullPath.includes('/users/999')) {
// This should return 404
console.log('Testing 404 scenario for user 999');
} else {
// Replace with a valid ID
transaction.fullPath = transaction.fullPath.replace('{id}', '1');
}
});
// Response validation hooks
hooks.afterEach((transaction) => {
if (transaction.test && transaction.test.status === 'fail') {
console.log(`Test failed: ${transaction.name}`);
console.log(`Expected: ${transaction.expected.body}`);
console.log(`Actual: ${transaction.real.body}`);
}
});
// Custom validation for specific endpoints
hooks.after('Users > User Collection > List All Users', (transaction) => {
if (transaction.real.statusCode === 200) {
const response = JSON.parse(transaction.real.body);
// Custom validation - check if response is an array
if (!Array.isArray(response)) {
transaction.test = {
status: 'fail',
message: 'Response should be an array',
expected: 'array',
actual: typeof response
};
}
// Validate user structure
if (response.length > 0) {
const user = response[0];
const requiredFields = ['id', 'name', 'email', 'status'];
const missingFields = requiredFields.filter(field => !(field in user));
if (missingFields.length > 0) {
transaction.test = {
status: 'fail',
message: `Missing required fields: ${missingFields.join(', ')}`,
expected: requiredFields.join(', '),
actual: Object.keys(user).join(', ')
};
}
}
}
});
module.exports = hooks;

2. Advanced Hooks with Database Setup

// test/hooks/database_hooks.js
const hooks = require('hooks');
const { Pool } = require('pg');
let dbPool = null;
hooks.beforeAll((transactions, done) => {
// Setup test database connection
dbPool = new Pool({
host: 'localhost',
port: 5432,
database: 'api_test',
user: 'test_user',
password: 'test_password'
});
done();
});
hooks.beforeEach((transaction) => {
// Clean and setup test data before each transaction
if (transaction.name.includes('User')) {
return dbPool.query(`
DELETE FROM users WHERE email LIKE 'test-%';
INSERT INTO users (id, name, email, status) VALUES 
('1', 'Test User', '[email protected]', 'active'),
('2', 'Another User', '[email protected]', 'active');
`).then(() => {
console.log('Test data setup completed');
}).catch(error => {
console.error('Failed to setup test data:', error);
});
}
});
hooks.afterAll((transactions, done) => {
// Cleanup database connection
if (dbPool) {
dbPool.end();
}
done();
});

Maven Integration

1. Maven Surefire Configuration

<!-- pom.xml -->
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.1.2</version>
<configuration>
<includes>
<include>**/*Test.java</include>
<include>**/*Tests.java</include>
</includes>
</configuration>
</plugin>
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>1.12.1</version>
<executions>
<execution>
<id>install node and npm</id>
<goals>
<goal>install-node-and-npm</goal>
</goals>
<configuration>
<nodeVersion>v18.17.0</nodeVersion>
</configuration>
</execution>
<execution>
<id>npm install</id>
<goals>
<goal>npm</goal>
</goals>
<configuration>
<arguments>install</arguments>
</configuration>
</execution>
<execution>
<id>dredd-test</id>
<goals>
<goal>npm</goal>
</goals>
<phase>test</phase>
<configuration>
<arguments>run test:api</arguments>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>

2. Java Test Runner for Dredd

package com.example.api.test;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.ActiveProfiles;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
public class DreddApiTest {
@LocalServerPort
private int port;
@Test
public void testApiContract() throws Exception {
String dreddCommand = String.format(
"npx dredd apiary.apib http://localhost:%d --hookfiles=dredd-hooks.js --reporter=dot",
port
);
Process process = Runtime.getRuntime().exec(dreddCommand);
int exitCode = process.waitFor();
// Read output
BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream())
);
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
assertTrue(exitCode == 0, "Dredd tests should pass");
}
}

CI/CD Integration

1. GitHub Actions Workflow

# .github/workflows/dredd.yml
name: API Contract Testing
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
api-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: api_test
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Set up Java
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: 'maven'
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
- name: Install Dredd
run: npm install -g dredd
- name: Build application
run: mvn clean compile -DskipTests
- name: Run Dredd tests
run: |
mvn spring-boot:start -Dspring-boot.run.profiles=test &
SERVER_PID=$!
sleep 30  # Wait for server to start
dredd apiary.apib http://localhost:8080 \
--hookfiles=dredd-hooks.js \
--reporter=junit \
--output=reports/dredd.xml \
--exitcode || true
kill $SERVER_PID
- name: Publish test results
uses: actions/upload-artifact@v3
if: always()
with:
name: dredd-report
path: reports/
- name: Fail if tests failed
run: |
if [ -f reports/dredd.xml ]; then
# Check if there are any failures in the JUnit report
if grep -q 'failures="[1-9]"' reports/dredd.xml; then
echo "Dredd tests failed"
exit 1
fi
else
echo "Dredd report not found"
exit 1
fi

2. GitLab CI Configuration

# .gitlab-ci.yml
stages:
- test
api_contract_test:
stage: test
image: maven:3.9-eclipse-temurin-17
services:
- postgres:15
variables:
POSTGRES_DB: api_test
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
before_script:
- apt-get update && apt-get install -y curl
- curl -fsSL https://deb.nodesource.com/setup_18.x | bash -
- apt-get install -y nodejs
- npm install -g dredd
script:
- mvn clean compile -DskipTests
- |
mvn spring-boot:start -Dspring-boot.run.profiles=test &
SERVER_PID=$!
sleep 30
dredd apiary.apib http://localhost:8080 \
--hookfiles=dredd-hooks.js \
--reporter=junit \
--output=reports/dredd.xml
kill $SERVER_PID
artifacts:
when: always
paths:
- reports/
reports:
junit: reports/dredd.xml

Best Practices

1. API Blueprint Organization

# Best Practices API Blueprint
FORMAT: 1A
# API Name
Overview of the API.
# Group Authentication
Authentication and authorization endpoints.
## Login [/api/auth/login]
### Authenticate [POST]
+ Request (application/json)
+ Attributes
+ username: [email protected] (string, required)
+ password: secret123 (string, required)
+ Response 200 (application/json)
+ Attributes (object)
+ token: eyJhbGci... (string)
+ type: Bearer (string)
+ expiresIn: 3600 (number)
# Group Users
User management endpoints.
## User Collection [/api/users]
### List Users [GET]
+ Parameters
+ page: 0 (number, optional)
+ size: 20 (number, optional)
+ Response 200 (application/json)
+ Attributes (object)
+ content (array[User])
+ pagination (Pagination)

2. Dredd Configuration Tips

# Best practices dredd.yml
reporter:
- junit
- spec
output:
- reports/dredd.junit.xml
- reports/dredd.html
custom:
order: sorted
only: []
skip: []
details: true
hooks-worker-timeout: 10000
hooks-worker-connect-timeout: 5000
header:
- "User-Agent: Dredd-API-Testing/1.0"
- "Accept: application/json"

Conclusion

Dredd provides Java teams with powerful API contract testing capabilities:

  • Contract Validation: Ensures API implementation matches documentation
  • Early Detection: Catches breaking changes before they reach production
  • CI/CD Integration: Seamless integration with build pipelines
  • Comprehensive Testing: Tests all API endpoints automatically
  • Documentation-Driven: Keeps documentation and implementation in sync

By integrating Dredd into your Java development workflow, you can maintain API consistency, improve documentation quality, and catch integration issues early in the development process.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper