Dredd is a language-agnostic tool for testing API documentation against the actual API implementation. It validates that your API Blueprint documentation accurately describes your API's behavior.
Core Concepts
What is Dredd?
- Command-line tool for API contract testing
- Validates API implementation against API Blueprint documentation
- Supports multiple API description formats (API Blueprint, OpenAPI)
- Language-agnostic - works with any API
Why Use Dredd?
- Contract Testing: Ensure API matches documentation
- Continuous Validation: Catch breaking changes early
- Documentation Accuracy: Keep docs in sync with implementation
- Regression Testing: Prevent API regressions
Setup and Dependencies
1. Prerequisites Installation
# Install Dredd globally npm install -g dredd # Or use npx (no installation required) npx dredd --version # Install API Blueprint parser (optional) npm install -g aglio
2. Maven Dependencies for Spring Boot API
<properties>
<spring-boot.version>3.1.0</spring-boot.version>
<rest-assured.version>5.3.0</rest-assured.version>
<junit.version>5.9.2</junit.version>
</properties>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<!-- Spring Boot Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>${spring-boot.version}</version>
<scope>test</scope>
</dependency>
<!-- REST Assured for API Testing -->
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<version>${rest-assured.version}</version>
<scope>test</scope>
</dependency>
<!-- JSON Schema Validator -->
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>json-schema-validator</artifactId>
<version>${rest-assured.version}</version>
<scope>test</scope>
</dependency>
<!-- JUnit 5 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
3. Project Structure
project/ ├── src/ │ ├── main/ │ │ └── java/ │ │ └── com/example/api/ │ └── test/ │ └── java/ │ └── com/example/api/ ├── docs/ │ ├── apiary.apib # API Blueprint documentation │ └── dredd/ │ ├── dredd.yml # Dredd configuration │ └── hooks/ # JavaScript hooks │ └── hooks.js └── pom.xml
API Blueprint Documentation
1. Complete API Blueprint Example
# API Blueprint for User Management API
FORMAT: 1A
# User Management API
This API provides user management functionality including user creation, retrieval, updating, and deletion.
# Group Users
## User Collection [/api/v1/users]
### List Users [GET]
+ Response 200 (application/json)
+ Attributes (array[User])
### Create User [POST]
+ Request (application/json)
+ Attributes (CreateUserRequest)
+ Response 201 (application/json)
+ Headers
Location: /api/v1/users/550e8400-e29b-41d4-a716-446655440000
+ Attributes (User)
+ Response 400 (application/json)
+ Attributes (ErrorResponse)
## User [/api/v1/users/{id}]
+ Parameters
+ id (string, required) - User ID in UUID format
### Get User [GET]
+ Response 200 (application/json)
+ Attributes (User)
+ Response 404 (application/json)
+ Attributes (ErrorResponse)
### Update User [PUT]
+ Request (application/json)
+ Attributes (UpdateUserRequest)
+ Response 200 (application/json)
+ Attributes (User)
+ Response 404 (application/json)
+ Attributes (ErrorResponse)
+ Response 400 (application/json)
+ Attributes (ErrorResponse)
### Delete User [DELETE]
+ Response 204
+ Response 404 (application/json)
+ Attributes (ErrorResponse)
## User Activation [/api/v1/users/{id}/activate]
+ Parameters
+ id (string, required) - User ID in UUID format
### Activate User [POST]
+ Response 200 (application/json)
+ Attributes (User)
+ Response 404 (application/json)
+ Attributes (ErrorResponse)
# Data Structures
## User (object)
+ id: 550e8400-e29b-41d4-a716-446655440000 (string, required) - User ID
+ firstName: John (string, required) - First name
+ lastName: Doe (string, required) - Last name
+ email: [email protected] (string, required) - Email address
+ status: active (string, required) - User status
+ createdAt: 2023-01-01T00:00:00Z (string, required) - Creation timestamp
+ updatedAt: 2023-01-01T00:00:00Z (string, required) - Last update timestamp
## CreateUserRequest (object)
+ firstName: John (string, required) - First name
+ lastName: Doe (string, required) - Last name
+ email: [email protected] (string, required) - Email address
## UpdateUserRequest (object)
+ firstName: John (string, optional) - First name
+ lastName: Doe (string, optional) - Last name
+ status: active (string, optional) - User status
## ErrorResponse (object)
+ error: true (boolean, required) - Error indicator
+ message: User not found (string, required) - Error message
+ code: USER_NOT_FOUND (string, optional) - Error code
+ timestamp: 2023-01-01T00:00:00Z (string, required) - Error timestamp
2. Enhanced API Blueprint with Examples
# Enhanced User API with Examples
FORMAT: 1A
# User API
## Users Collection [/api/v1/users]
### List All Users [GET]
+ Response 200 (application/json)
+ Body
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"firstName": "John",
"lastName": "Doe",
"email": "[email protected]",
"status": "active",
"createdAt": "2023-01-01T00:00:00Z",
"updatedAt": "2023-01-01T00:00:00Z"
},
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"firstName": "Jane",
"lastName": "Smith",
"email": "[email protected]",
"status": "inactive",
"createdAt": "2023-01-02T00:00:00Z",
"updatedAt": "2023-01-02T00:00:00Z"
}
]
### Create a New User [POST]
Create a new user in the system.
+ Request (application/json)
+ Attributes
+ firstName: John (string, required) - User's first name
+ lastName: Doe (string, required) - User's last name
+ email: [email protected] (string, required) - User's email address
+ Body
{
"firstName": "John",
"lastName": "Doe",
"email": "[email protected]"
}
+ Response 201 (application/json)
+ Headers
Location: /api/v1/users/550e8400-e29b-41d4-a716-446655440000
+ Body
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"firstName": "John",
"lastName": "Doe",
"email": "[email protected]",
"status": "active",
"createdAt": "2023-01-01T00:00:00Z",
"updatedAt": "2023-01-01T00:00:00Z"
}
+ Response 400 (application/json)
+ Body
{
"error": true,
"message": "Email already exists",
"code": "EMAIL_EXISTS",
"timestamp": "2023-01-01T00:00:00Z"
}
## User [/api/v1/users/{id}]
+ Parameters
+ id: 550e8400-e29b-41d4-a716-446655440000 (string, required) - The user ID
### Get User [GET]
Retrieve a specific user by ID.
+ Response 200 (application/json)
+ Body
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"firstName": "John",
"lastName": "Doe",
"email": "[email protected]",
"status": "active",
"createdAt": "2023-01-01T00:00:00Z",
"updatedAt": "2023-01-01T00:00:00Z"
}
+ Response 404 (application/json)
+ Body
{
"error": true,
"message": "User not found",
"code": "USER_NOT_FOUND",
"timestamp": "2023-01-01T00:00:00Z"
}
Dredd Configuration
1. Dredd Configuration File
# dredd.yml
dry-run: null
hookfiles:
- "./docs/dredd/hooks/hooks.js"
language: nodejs
sandbox: false
server: null
server-wait: 3
init: false
custom: {}
names: false
only: []
output:
- "cli"
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: ./docs/apiary.apib
endpoint: 'http://localhost:8080'
2. Package.json for Dredd Hooks
{
"name": "api-dredd-tests",
"version": "1.0.0",
"description": "Dredd tests for Java API",
"scripts": {
"test:dredd": "dredd",
"test:dredd:ci": "dredd --reporter=junit --output=reports/dredd.xml",
"test:dredd:html": "dredd --reporter=html --output=reports/dredd.html"
},
"devDependencies": {
"dredd": "latest",
"chai": "^4.3.7",
"sqlite3": "^5.1.6"
}
}
3. Dredd Hooks (JavaScript)
// docs/dredd/hooks/hooks.js
const hooks = require('dredd-hooks');
const chai = require('chai');
const expect = chai.expect;
// Test data storage
let testUsers = {};
let authToken = null;
hooks.beforeAll(function(transactions, done) {
console.log('Starting Dredd tests...');
// Initialize test data
testUsers = {
existingUser: {
id: '550e8400-e29b-41d4-a716-446655440000',
firstName: 'John',
lastName: 'Doe',
email: '[email protected]',
status: 'active',
createdAt: '2023-01-01T00:00:00Z',
updatedAt: '2023-01-01T00:00:00Z'
},
newUser: {
firstName: 'Jane',
lastName: 'Smith',
email: '[email protected]'
}
};
done();
});
hooks.beforeEach(function(transaction, done) {
// Add common headers
transaction.request.headers['Content-Type'] = 'application/json';
transaction.request.headers['Accept'] = 'application/json';
transaction.request.headers['User-Agent'] = 'Dredd/1.0.0';
// Add authentication if available
if (authToken) {
transaction.request.headers['Authorization'] = `Bearer ${authToken}`;
}
console.log(`Preparing test: ${transaction.name}`);
done();
});
// User-specific hooks
hooks.before('Users > User Collection > Create User', function(transaction, done) {
// Set up request body for user creation
transaction.request.body = JSON.stringify(testUsers.newUser);
done();
});
hooks.before('Users > User > Get User', function(transaction, done) {
// Replace {id} in URL with actual test user ID
const userId = testUsers.existingUser.id;
transaction.fullPath = transaction.fullPath.replace('{id}', userId);
transaction.request.uri = transaction.request.uri.replace('{id}', userId);
done();
});
hooks.before('Users > User > Update User', function(transaction, done) {
// Replace {id} and set update data
const userId = testUsers.existingUser.id;
transaction.fullPath = transaction.fullPath.replace('{id}', userId);
transaction.request.uri = transaction.request.uri.replace('{id}', userId);
const updateData = {
firstName: 'John Updated',
lastName: 'Doe Updated'
};
transaction.request.body = JSON.stringify(updateData);
done();
});
hooks.before('Users > User > Delete User', function(transaction, done) {
// Replace {id} in URL
const userId = testUsers.existingUser.id;
transaction.fullPath = transaction.fullPath.replace('{id}', userId);
transaction.request.uri = transaction.request.uri.replace('{id}', userId);
done();
});
// Response validation hooks
hooks.after('Users > User Collection > Create User', function(transaction, done) {
try {
const response = JSON.parse(transaction.real.body);
// Validate response structure
expect(response).to.have.property('id');
expect(response).to.have.property('firstName');
expect(response).to.have.property('lastName');
expect(response).to.have.property('email');
expect(response).to.have.property('status');
expect(response).to.have.property('createdAt');
expect(response).to.have.property('updatedAt');
// Store the created user for later tests
testUsers.createdUser = response;
console.log(`Created user with ID: ${response.id}`);
} catch (error) {
console.error('Response validation failed:', error.message);
}
done();
});
hooks.after('Users > User > Get User', function(transaction, done) {
if (transaction.real.statusCode === 200) {
try {
const response = JSON.parse(transaction.real.body);
// Validate user data
expect(response.id).to.equal(testUsers.existingUser.id);
expect(response.firstName).to.equal(testUsers.existingUser.firstName);
expect(response.email).to.equal(testUsers.existingUser.email);
} catch (error) {
console.error('Get user validation failed:', error.message);
}
}
done();
});
// Error scenario hooks
hooks.before('Users > User Collection > Create User > 400 Response', function(transaction, done) {
// Create a request that will cause a 400 error (duplicate email)
const duplicateUser = {
firstName: 'Duplicate',
lastName: 'User',
email: testUsers.existingUser.email // This should cause a conflict
};
transaction.request.body = JSON.stringify(duplicateUser);
done();
});
hooks.after('Users > User Collection > Create User > 400 Response', function(transaction, done) {
if (transaction.real.statusCode === 400) {
try {
const response = JSON.parse(transaction.real.body);
expect(response).to.have.property('error', true);
expect(response).to.have.property('message');
expect(response).to.have.property('timestamp');
} catch (error) {
console.error('Error response validation failed:', error.message);
}
}
done();
});
// Cleanup hooks
hooks.afterAll(function(transactions, done) {
console.log('Dredd tests completed');
console.log(`Total transactions: ${transactions.length}`);
// Log summary
const passed = transactions.filter(t => t.test && t.test.status === 'pass').length;
const failed = transactions.filter(t => t.test && t.test.status === 'fail').length;
const skipped = transactions.filter(t => t.test && t.test.status === 'skip').length;
console.log(`Results: ${passed} passed, ${failed} failed, ${skipped} skipped`);
done();
});
module.exports = hooks;
Java API Implementation
1. Spring Boot Application
package com.example.api;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class UserApiApplication {
public static void main(String[] args) {
SpringApplication.run(UserApiApplication.class, args);
}
}
2. Application Configuration
# application.yml server: port: 8080 servlet: context-path: / spring: application: name: user-api jackson: serialization: write-dates-as-timestamps: false date-format: com.fasterxml.jackson.databind.util.StdDateFormat property-naming-strategy: SNAKE_CASE management: endpoints: web: exposure: include: health,info endpoint: health: show-details: always logging: level: com.example.api: DEBUG org.springframework.web: INFO
3. Domain Models
package com.example.api.model;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.persistence.*;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "users")
@JsonInclude(JsonInclude.Include.NON_NULL)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@NotBlank
@Size(max = 100)
@Column(nullable = false)
private String firstName;
@NotBlank
@Size(max = 100)
@Column(nullable = false)
private String lastName;
@NotBlank
@Email
@Size(max = 255)
@Column(nullable = false, unique = true)
private String email;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private UserStatus status = UserStatus.ACTIVE;
@Column(name = "created_at", nullable = false, updatable = false)
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'")
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'")
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
// Constructors
public User() {}
public User(String firstName, String lastName, String email) {
this.firstName = firstName;
this.lastName = lastName;
this.email = email;
}
// Getters and Setters
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public String getFirstName() { return firstName; }
public void setFirstName(String firstName) { this.firstName = firstName; }
public String getLastName() { return lastName; }
public void setLastName(String lastName) { this.lastName = lastName; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public UserStatus getStatus() { return status; }
public void setStatus(UserStatus status) { this.status = status; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}
enum UserStatus {
ACTIVE, INACTIVE, SUSPENDED
}
// DTOs for API
public class CreateUserRequest {
@NotBlank
@Size(max = 100)
private String firstName;
@NotBlank
@Size(max = 100)
private String lastName;
@NotBlank
@Email
@Size(max = 255)
private String email;
// Getters and Setters
public String getFirstName() { return firstName; }
public void setFirstName(String firstName) { this.firstName = firstName; }
public String getLastName() { return lastName; }
public void setLastName(String lastName) { this.lastName = lastName; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
}
public class UpdateUserRequest {
@Size(max = 100)
private String firstName;
@Size(max = 100)
private String lastName;
private UserStatus status;
// Getters and Setters
public String getFirstName() { return firstName; }
public void setFirstName(String firstName) { this.firstName = firstName; }
public String getLastName() { return lastName; }
public void setLastName(String lastName) { this.lastName = lastName; }
public UserStatus getStatus() { return status; }
public void setStatus(UserStatus status) { this.status = status; }
}
public class ErrorResponse {
private final boolean error = true;
private final String message;
private final String code;
private final LocalDateTime timestamp;
public ErrorResponse(String message) {
this(message, null);
}
public ErrorResponse(String message, String code) {
this.message = message;
this.code = code;
this.timestamp = LocalDateTime.now();
}
// Getters
public boolean isError() { return error; }
public String getMessage() { return message; }
public String getCode() { return code; }
public LocalDateTime getTimestamp() { return timestamp; }
}
4. Repository Layer
package com.example.api.repository;
import com.example.api.model.User;
import com.example.api.model.UserStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface UserRepository extends JpaRepository<User, UUID> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
long countByStatus(UserStatus status);
}
5. Service Layer
package com.example.api.service;
import com.example.api.model.User;
import com.example.api.model.UserStatus;
import com.example.api.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Service
@Transactional
public class UserService {
private static final Logger LOG = LoggerFactory.getLogger(UserService.class);
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Transactional(readOnly = true)
public List<User> getAllUsers() {
LOG.debug("Fetching all users");
return userRepository.findAll();
}
@Transactional(readOnly = true)
public Optional<User> getUserById(UUID id) {
LOG.debug("Fetching user by ID: {}", id);
return userRepository.findById(id);
}
public User createUser(User user) {
LOG.info("Creating user with email: {}", user.getEmail());
if (userRepository.existsByEmail(user.getEmail())) {
throw new UserServiceException("User with email " + user.getEmail() + " already exists");
}
User savedUser = userRepository.save(user);
LOG.info("Successfully created user: {}", savedUser.getId());
return savedUser;
}
public User updateUser(UUID id, User userUpdate) {
LOG.info("Updating user: {}", id);
User existingUser = userRepository.findById(id)
.orElseThrow(() -> new UserServiceException("User not found: " + id));
// Update fields if provided
if (userUpdate.getFirstName() != null) {
existingUser.setFirstName(userUpdate.getFirstName());
}
if (userUpdate.getLastName() != null) {
existingUser.setLastName(userUpdate.getLastName());
}
if (userUpdate.getStatus() != null) {
existingUser.setStatus(userUpdate.getStatus());
}
User updatedUser = userRepository.save(existingUser);
LOG.info("Successfully updated user: {}", id);
return updatedUser;
}
public void deleteUser(UUID id) {
LOG.info("Deleting user: {}", id);
if (!userRepository.existsById(id)) {
throw new UserServiceException("User not found: " + id);
}
userRepository.deleteById(id);
LOG.info("Successfully deleted user: {}", id);
}
public User activateUser(UUID id) {
LOG.info("Activating user: {}", id);
User user = userRepository.findById(id)
.orElseThrow(() -> new UserServiceException("User not found: " + id));
user.setStatus(UserStatus.ACTIVE);
User activatedUser = userRepository.save(user);
LOG.info("Successfully activated user: {}", id);
return activatedUser;
}
}
class UserServiceException extends RuntimeException {
public UserServiceException(String message) {
super(message);
}
}
6. REST Controllers
package com.example.api.controller;
import com.example.api.model.*;
import com.example.api.service.UserService;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/users")
public class UserController {
private static final Logger LOG = LoggerFactory.getLogger(UserController.class);
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping
public ResponseEntity<List<User>> getUsers() {
LOG.info("Fetching all users");
List<User> users = userService.getAllUsers();
return ResponseEntity.ok(users);
}
@GetMapping("/{id}")
public ResponseEntity<User> getUserById(@PathVariable UUID id) {
LOG.info("Fetching user by ID: {}", id);
return userService.getUserById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<User> createUser(@Valid @RequestBody CreateUserRequest request) {
LOG.info("Creating user with email: {}", request.getEmail());
User user = new User(request.getFirstName(), request.getLastName(), request.getEmail());
User createdUser = userService.createUser(user);
return ResponseEntity
.created(URI.create("/api/v1/users/" + createdUser.getId()))
.body(createdUser);
}
@PutMapping("/{id}")
public ResponseEntity<User> updateUser(
@PathVariable UUID id,
@Valid @RequestBody UpdateUserRequest request) {
LOG.info("Updating user: {}", id);
User userUpdate = new User();
userUpdate.setFirstName(request.getFirstName());
userUpdate.setLastName(request.getLastName());
userUpdate.setStatus(request.getStatus());
try {
User updatedUser = userService.updateUser(id, userUpdate);
return ResponseEntity.ok(updatedUser);
} catch (UserServiceException e) {
return ResponseEntity.notFound().build();
}
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable UUID id) {
LOG.info("Deleting user: {}", id);
try {
userService.deleteUser(id);
return ResponseEntity.noContent().build();
} catch (UserServiceException e) {
return ResponseEntity.notFound().build();
}
}
@PostMapping("/{id}/activate")
public ResponseEntity<User> activateUser(@PathVariable UUID id) {
LOG.info("Activating user: {}", id);
try {
User activatedUser = userService.activateUser(id);
return ResponseEntity.ok(activatedUser);
} catch (UserServiceException e) {
return ResponseEntity.notFound().build();
}
}
@ExceptionHandler(UserServiceException.class)
public ResponseEntity<ErrorResponse> handleUserServiceException(UserServiceException e) {
LOG.warn("User service exception: {}", e.getMessage());
if (e.getMessage().contains("not found")) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse(e.getMessage(), "USER_NOT_FOUND"));
} else if (e.getMessage().contains("already exists")) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse(e.getMessage(), "EMAIL_EXISTS"));
} else {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse(e.getMessage()));
}
}
}
Testing Infrastructure
1. Test Configuration
# application-test.yml spring: datasource: url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE driver-class-name: org.h2.Driver username: sa password: jpa: database-platform: org.hibernate.dialect.H2Dialect hibernate: ddl-auto: create-drop show-sql: false h2: console: enabled: true logging: level: com.example.api: DEBUG
2. Test Data Setup
package com.example.api.testdata;
import com.example.api.model.User;
import com.example.api.model.UserStatus;
import com.example.api.repository.UserRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import java.util.UUID;
@Component
@Profile("test")
public class TestDataLoader implements CommandLineRunner {
private final UserRepository userRepository;
public TestDataLoader(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public void run(String... args) throws Exception {
// Create test users for Dredd testing
User testUser = new User("John", "Doe", "[email protected]");
testUser.setId(UUID.fromString("550e8400-e29b-41d4-a716-446655440000"));
testUser.setStatus(UserStatus.ACTIVE);
userRepository.save(testUser);
System.out.println("Test data loaded successfully");
}
}
3. Integration Test Base Class
package com.example.api.integration;
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.ActiveProfiles;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
public abstract class BaseIntegrationTest {
@LocalServerPort
protected int port;
@BeforeEach
protected void setUp() {
RestAssured.port = port;
RestAssured.basePath = "/api/v1";
RestAssured.enableLoggingOfRequestAndResponseIfValidationFails();
}
}
4. Dredd Test Runner
package com.example.api.dredd;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ConfigurableApplicationContext;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.concurrent.TimeUnit;
public class DreddTestRunner {
private static final Logger LOG = LoggerFactory.getLogger(DreddTestRunner.class);
public static void main(String[] args) {
LOG.info("Starting Dredd API contract tests...");
// Start the Spring Boot application
ConfigurableApplicationContext context = null;
Process dreddProcess = null;
try {
// Start the application
context = SpringApplication.run(
com.example.api.UserApiApplication.class,
"--spring.profiles.active=test"
);
// Wait for application to start
Thread.sleep(5000);
// Run Dredd tests
dreddProcess = runDreddTests();
int exitCode = dreddProcess.waitFor();
LOG.info("Dredd tests completed with exit code: {}", exitCode);
System.exit(exitCode);
} catch (Exception e) {
LOG.error("Dredd test execution failed", e);
System.exit(1);
} finally {
// Cleanup
if (context != null) {
context.close();
}
if (dreddProcess != null) {
dreddProcess.destroy();
}
}
}
private static Process runDreddTests() throws IOException, InterruptedException {
ProcessBuilder processBuilder = new ProcessBuilder();
if (System.getProperty("os.name").toLowerCase().contains("win")) {
processBuilder.command("cmd.exe", "/c", "npx", "dredd");
} else {
processBuilder.command("npx", "dredd");
}
processBuilder.directory(new java.io.File("."));
processBuilder.redirectErrorStream(true);
Process process = processBuilder.start();
// Read output
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
LOG.info("[Dredd] {}", line);
}
}
return process;
}
}
CI/CD Integration
1. Maven Configuration for Dredd
<!-- Add to pom.xml --> <profiles> <profile> <id>dredd-tests</id> <build> <plugins> <plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>exec-maven-plugin</artifactId> <version>3.1.0</version> <executions> <execution> <id>run-dredd-tests</id> <phase>integration-test</phase> <goals> <goal>java</goal> </goals> <configuration> <mainClass>com.example.api.dredd.DreddTestRunner</mainClass> <classpathScope>test</classpathScope> <arguments> <argument>--spring.profiles.active=test</argument> </arguments> </configuration> </execution> </executions> </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.16.0</nodeVersion> <npmVersion>9.5.1</npmVersion> </configuration> </execution> <execution> <id>npm-install</id> <goals> <goal>npm</goal> </goals> <configuration> <arguments>install</arguments> </configuration> </execution> </executions> </plugin> </plugins> </build> </profile> </profiles>
2. GitHub Actions Workflow
# .github/workflows/dredd.yml name: API Contract Tests on: push: branches: [ main, develop ] pull_request: branches: [ main ] jobs: dredd-tests: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up JDK 21 uses: actions/setup-java@v4 with: java-version: '21' distribution: 'temurin' cache: 'maven' - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: '18' cache: 'npm' cache-dependency-path: package.json - name: Install dependencies run: | npm install mvn dependency:resolve - name: Run Dredd tests run: | mvn clean test-compile -Pdredd-tests exec:java@run-dredd-tests - name: Upload test results uses: actions/upload-artifact@v3 if: always() with: name: dredd-reports path: | reports/ retention-days: 30
3. Docker Configuration for Testing
# Dockerfile for Dredd testing FROM eclipse-temurin:21-jre # Install Node.js and npm RUN apt-get update && \ apt-get install -y curl && \ curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \ apt-get install -y nodejs # Create app directory WORKDIR /app # Copy application JAR COPY target/*.jar app.jar # Copy API documentation and Dredd configuration COPY docs/ ./docs/ COPY package*.json ./ # Install Dredd RUN npm install # Expose port EXPOSE 8080 # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD curl -f http://localhost:8080/actuator/health || exit 1 # Run Dredd tests CMD ["sh", "-c", "java -jar app.jar & sleep 10 && npx dredd"]
Advanced Dredd Hooks
1. Database Hooks
// docs/dredd/hooks/database-hooks.js
const hooks = require('dredd-hooks');
const sqlite3 = require('sqlite3').verbose();
let db = null;
hooks.beforeAll(function(transactions, done) {
// Initialize test database
db = new sqlite3.Database(':memory:');
db.serialize(function() {
db.run(`CREATE TABLE users (
id TEXT PRIMARY KEY,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`);
// Insert test data
const stmt = db.prepare(`INSERT INTO users
(id, first_name, last_name, email, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`);
stmt.run([
'550e8400-e29b-41d4-a716-446655440000',
'John',
'Doe',
'[email protected]',
'active',
'2023-01-01T00:00:00Z',
'2023-01-01T00:00:00Z'
]);
stmt.finalize();
});
done();
});
hooks.beforeEach(function(transaction, done) {
// Add database connection to transaction context
transaction.db = db;
done();
});
hooks.after('Users > User Collection > Create User', function(transaction, done) {
// Verify user was created in database
const response = JSON.parse(transaction.real.body);
db.get(
"SELECT * FROM users WHERE id = ?",
[response.id],
function(err, row) {
if (err) {
console.error('Database verification failed:', err);
} else if (row) {
console.log('User successfully created in database');
} else {
console.error('User not found in database after creation');
}
done();
}
);
});
hooks.afterAll(function(transactions, done) {
// Close database connection
if (db) {
db.close();
}
done();
});
2. Authentication Hooks
// docs/dredd/hooks/auth-hooks.js
const hooks = require('dredd-hooks');
const chai = require('chai');
const expect = chai.expect;
let authTokens = {};
hooks.before('Users > User Collection > Create User', function(transaction, done) {
// Add authentication for protected endpoints
if (!authTokens.admin) {
// Simulate login to get token
transaction.request.headers['Authorization'] = 'Bearer mock-admin-token';
authTokens.admin = 'mock-admin-token';
} else {
transaction.request.headers['Authorization'] = `Bearer ${authTokens.admin}`;
}
done();
});
hooks.before('Users > User > Get User', function(transaction, done) {
// Add authentication
transaction.request.headers['Authorization'] = 'Bearer mock-user-token';
done();
});
hooks.after('Users > User Collection > Create User', function(transaction, done) {
// Verify response includes proper headers
if (transaction.real.statusCode === 201) {
expect(transaction.real.headers).to.have.property('location');
expect(transaction.real.headers.location).to.match(/\/api\/v1\/users\/[a-f0-9-]+/);
}
done();
});
3. Performance Hooks
// docs/dredd/hooks/performance-hooks.js
const hooks = require('dredd-hooks');
const performanceThresholds = {
'Users > User Collection > List Users': 1000, // 1 second
'Users > User > Get User': 500, // 500ms
'Users > User Collection > Create User': 2000, // 2 seconds
};
hooks.beforeEach(function(transaction, done) {
// Start timing
transaction.startTime = Date.now();
done();
});
hooks.afterEach(function(transaction, done) {
// Check performance
const endTime = Date.now();
const duration = endTime - transaction.startTime;
const threshold = performanceThresholds[transaction.name];
if (threshold && duration > threshold) {
console.warn(`Performance warning: ${transaction.name} took ${duration}ms (threshold: ${threshold}ms)`);
}
done();
});
Best Practices
1. Dredd Configuration Best Practices
# dredd-best-practices.yml # Comprehensive Dredd configuration dry-run: null hookfiles: - "./docs/dredd/hooks/*.js" language: nodejs sandbox: false server: "java -jar target/api.jar --spring.profiles.active=test" server-wait: 10 init: false custom: api-name: "User Management API" environment: "test" names: false only: [] output: - "cli" - "junit" - "html" header: - "User-Agent: Dredd/1.0.0" - "Accept: application/json" sorted: false user: null inline-errors: false details: false method: [] color: true level: verbose timestamp: false silent: false path: [] hooks-worker-timeout: 10000 hooks-worker-connect-timeout: 5000 hooks-worker-connect-retry: 1000 hooks-worker-after-connect-wait: 2000 hooks-worker-term-timeout: 10000 hooks-worker-term-retry: 1000 config: ./dredd.yml blueprint: ./docs/apiary.apib endpoint: 'http://localhost:8080' reporter: - "junit" - "html" output: - "reports/dredd.junit.xml" - "reports/dredd.html"
2. API Blueprint Best Practices
# API Blueprint Best Practices Template FORMAT: 1A # API Name Overview of the API and its purpose. # Authentication Describe authentication mechanism if any. ## Access Token [POST /auth/token] + Request (application/json) + Attributes + username: [email protected] (string, required) + password: secret (string, required) + Response 200 (application/json) + Attributes + access_token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 (string, required) + token_type: bearer (string, required) + expires_in: 3600 (number, required) # Group Resource Group Description of the resource group. ## Resource Collection [/resources] ### Operation Name [VERB] Description of the operation. + Parameters + param_name (type, optional) - Parameter description + Request (content-type) + Headers Header-Name: header-value + Attributes (DataStructureName) + Body Example request body + Response Status (content-type) + Headers Header-Name: header-value + Attributes (DataStructureName) + Body Example response body # Data Structures ## DataStructureName (object) + property: value (type, required) - Property description + optionalProperty: value (type, optional) - Optional property description
Conclusion
Implementing Dredd for API Blueprint testing in Java provides:
- Contract Validation: Ensure API implementation matches documentation
- Automated Testing: Continuous validation of API contracts
- Documentation Accuracy: Keep API Blueprint in sync with implementation
- Regression Prevention: Catch breaking changes early
Key benefits demonstrated:
- Language-agnostic testing with Dredd
- Comprehensive hook system for test setup and validation
- CI/CD integration for automated contract testing
- Performance monitoring alongside functional testing
- Database integration for end-to-end validation
- Multiple report formats for different needs
This approach ensures your Java API remains consistent with its documentation, providing reliable API contracts for consumers while maintaining development velocity through automated testing.