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.