Mocker: Service Virtualization with Spring Boot for Mocking REST, SOAP, and TCP Services

Learn how to build a service virtualization platform using Spring Boot. Record and replay service interactions, mock unavailable dependencies, and enable parallel development with dynamic response templating.

GT
Gonnect Team
January 14, 202412 min readView on GitHub
Spring BootGroovyService VirtualizationRESTSOAPTCP

Introduction

Modern software development often involves integrating with numerous external services - payment gateways, third-party APIs, legacy systems, and partner services. But what happens when:

  • The external service is not yet developed?
  • The service is unavailable due to maintenance?
  • You need to test edge cases that are hard to reproduce?
  • Rate limits or costs prevent unlimited testing?

Service Virtualization addresses these challenges by creating simulated versions of dependent services. The Mocker project provides a comprehensive service virtualization platform using Spring Boot, supporting REST, SOAP, and TCP protocols with intelligent request-response matching.

Key Insight: Service virtualization enables parallel development, consistent testing environments, and eliminates dependencies on external service availability - essential for modern CI/CD pipelines.

Microservices Architecture

Loading diagram...

What is Service Virtualization?

Service Virtualization simulates the behavior of dependent services, enabling:

CapabilityBenefit
Record & ReplayCapture real interactions and replay them
Parallel DevelopmentFrontend teams work while backend is in progress
Consistent TestingReproducible test scenarios every time
Edge Case TestingSimulate errors, timeouts, and unusual responses
Cost ReductionAvoid expensive third-party API calls during testing

How Mocker Works

+------------------+     +------------------+     +------------------+
|   Your           |     |                  |     |   Target         |
|   Application    |---->|     Mocker       |---->|   Service        |
|                  |     |                  |     |   (Optional)     |
+------------------+     +------------------+     +------------------+
                               |
                               | Hash-based
                               | Lookup
                               v
                         +------------------+
                         |   In-Memory      |
                         |   Mock Database  |
                         +------------------+

Architecture Overview

Mocker implements a smart matching strategy:

  1. First Request: If no mock exists, forwards to target service and records response
  2. Subsequent Requests: Returns cached response based on hash matching
  3. Manual Mocks: Pre-configured responses for specific scenarios

Microservices Architecture

Loading diagram...

Hash-Based Matching

The matching key is computed from:

  • URL Path
  • HTTP Method
  • Request Body (normalized)
// Simplified hash computation
String hashKey = computeHash(
    request.getMethod(),
    request.getPath(),
    normalizeBody(request.getBody())
);

Project Setup

Prerequisites

  • JDK 8 or higher
  • Maven 3.5.0+
  • Lombok IDE configuration
  • jpa-eclipselink Spring Boot auto-configuration

Maven Dependencies

<dependencies>
    <!-- Spring Boot -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Data persistence -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>

    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>

    <!-- Groovy for dynamic templates -->
    <dependency>
        <groupId>org.codehaus.groovy</groupId>
        <artifactId>groovy-all</artifactId>
        <version>3.0.9</version>
    </dependency>

    <!-- Swagger Documentation -->
    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger2</artifactId>
        <version>2.9.2</version>
    </dependency>

    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger-ui</artifactId>
        <version>2.9.2</version>
    </dependency>

    <!-- Lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

Project Structure

Mocker/
├── src/
│   └── main/
│       ├── java/
│       │   └── com/
│       │       └── gonnect/
│       │           └── mocker/
│       │               ├── MockerApplication.java
│       │               ├── controller/
│       │               │   ├── MockController.java
│       │               │   └── AdminController.java
│       │               ├── service/
│       │               │   ├── MockService.java
│       │               │   └── TemplateService.java
│       │               ├── repository/
│       │               │   └── MockRepository.java
│       │               ├── model/
│       │               │   └── MockDefinition.java
│       │               └── template/
│       │                   ├── SimpleResponseTemplate.java
│       │                   └── SmartResponseTemplate.java
│       ├── groovy/
│       │   └── templates/
│       │       └── DynamicResponseTemplate.groovy
│       └── resources/
│           └── application.yml
└── pom.xml

Core Implementation

Mock Definition Model

package com.gonnect.mocker.model;

import lombok.Data;
import javax.persistence.*;

@Data
@Entity
@Table(name = "mock_definitions")
public class MockDefinition {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private String method;

    @Column(nullable = false)
    private String path;

    @Column(nullable = false)
    private String requestHash;

    @Lob
    private String requestBody;

    @Lob
    @Column(nullable = false)
    private String responseBody;

    private int responseStatus = 200;

    private String contentType = "application/json";

    private long delayMs = 0;

    @Lob
    private String templateClass;

    private boolean active = true;

    @Column(updatable = false)
    private java.time.LocalDateTime createdAt;

    private java.time.LocalDateTime lastAccessedAt;

    private long accessCount = 0;

    @PrePersist
    protected void onCreate() {
        createdAt = java.time.LocalDateTime.now();
    }
}

Mock Repository

package com.gonnect.mocker.repository;

import com.gonnect.mocker.model.MockDefinition;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
public interface MockRepository extends JpaRepository<MockDefinition, Long> {

    Optional<MockDefinition> findByRequestHashAndActiveTrue(String requestHash);

    List<MockDefinition> findByPathContaining(String path);

    List<MockDefinition> findByActiveTrue();

    @Query("SELECT m FROM MockDefinition m ORDER BY m.accessCount DESC")
    List<MockDefinition> findMostAccessed();
}

Mock Service

package com.gonnect.mocker.service;

import com.gonnect.mocker.model.MockDefinition;
import com.gonnect.mocker.repository.MockRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.Optional;

@Slf4j
@Service
@RequiredArgsConstructor
public class MockService {

    private final MockRepository mockRepository;
    private final TemplateService templateService;
    private final RestTemplate restTemplate;

    @Value("${mocker.target.url:}")
    private String targetUrl;

    @Value("${mocker.record.enabled:true}")
    private boolean recordEnabled;

    /**
     * Process a mock request - return cached response or record new one
     */
    public ResponseEntity<String> processRequest(
            String method,
            String path,
            String body,
            HttpHeaders headers) {

        String requestHash = computeHash(method, path, body);
        log.debug("Processing request: {} {} [hash={}]", method, path, requestHash);

        // Check for existing mock
        Optional<MockDefinition> existingMock =
            mockRepository.findByRequestHashAndActiveTrue(requestHash);

        if (existingMock.isPresent()) {
            return serveMock(existingMock.get(), body);
        }

        // No mock found - forward to target and record if enabled
        if (recordEnabled && !targetUrl.isEmpty()) {
            return forwardAndRecord(method, path, body, headers, requestHash);
        }

        // Return 404 if no mock and no target configured
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body("{\"error\": \"No mock found for this request\"}");
    }

    private ResponseEntity<String> serveMock(MockDefinition mock, String requestBody) {
        log.info("Serving mock: {} [accessCount={}]",
            mock.getName(), mock.getAccessCount());

        // Update access statistics
        mock.setLastAccessedAt(LocalDateTime.now());
        mock.setAccessCount(mock.getAccessCount() + 1);
        mockRepository.save(mock);

        // Apply response template if configured
        String responseBody = mock.getResponseBody();
        if (mock.getTemplateClass() != null && !mock.getTemplateClass().isEmpty()) {
            responseBody = templateService.applyTemplate(
                mock.getTemplateClass(),
                requestBody,
                responseBody);
        }

        // Apply delay if configured
        if (mock.getDelayMs() > 0) {
            try {
                Thread.sleep(mock.getDelayMs());
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }

        return ResponseEntity
            .status(mock.getResponseStatus())
            .contentType(MediaType.parseMediaType(mock.getContentType()))
            .body(responseBody);
    }

    private ResponseEntity<String> forwardAndRecord(
            String method,
            String path,
            String body,
            HttpHeaders headers,
            String requestHash) {

        try {
            String fullUrl = targetUrl + path;
            log.info("Forwarding to target: {} {}", method, fullUrl);

            HttpEntity<String> entity = new HttpEntity<>(body, headers);
            ResponseEntity<String> response = restTemplate.exchange(
                fullUrl,
                HttpMethod.valueOf(method),
                entity,
                String.class);

            // Record the response
            MockDefinition mock = new MockDefinition();
            mock.setName("Auto-recorded: " + method + " " + path);
            mock.setMethod(method);
            mock.setPath(path);
            mock.setRequestHash(requestHash);
            mock.setRequestBody(body);
            mock.setResponseBody(response.getBody());
            mock.setResponseStatus(response.getStatusCodeValue());
            mock.setContentType(
                response.getHeaders().getContentType() != null
                    ? response.getHeaders().getContentType().toString()
                    : "application/json");

            mockRepository.save(mock);
            log.info("Recorded mock: {}", mock.getName());

            return response;

        } catch (Exception e) {
            log.error("Error forwarding request", e);
            return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
                .body("{\"error\": \"" + e.getMessage() + "\"}");
        }
    }

    private String computeHash(String method, String path, String body) {
        try {
            String input = method + "|" + path + "|" + normalizeBody(body);
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
            StringBuilder hexString = new StringBuilder();
            for (byte b : hash) {
                String hex = Integer.toHexString(0xff & b);
                if (hex.length() == 1) hexString.append('0');
                hexString.append(hex);
            }
            return hexString.toString();
        } catch (Exception e) {
            throw new RuntimeException("Hash computation failed", e);
        }
    }

    private String normalizeBody(String body) {
        if (body == null) return "";
        // Remove whitespace for consistent hashing
        return body.replaceAll("\\s+", "");
    }
}

Mock Controller

package com.gonnect.mocker.controller;

import com.gonnect.mocker.service.MockService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;

@RestController
@RequestMapping("/mock")
@RequiredArgsConstructor
public class MockController {

    private final MockService mockService;

    @RequestMapping(value = "/**", method = {
        RequestMethod.GET, RequestMethod.POST,
        RequestMethod.PUT, RequestMethod.DELETE,
        RequestMethod.PATCH
    })
    public ResponseEntity<String> handleRequest(
            HttpServletRequest request,
            @RequestBody(required = false) String body,
            @RequestHeader HttpHeaders headers) {

        String path = request.getRequestURI().replace("/mock", "");
        String method = request.getMethod();

        return mockService.processRequest(method, path, body, headers);
    }
}

Admin Controller

package com.gonnect.mocker.controller;

import com.gonnect.mocker.model.MockDefinition;
import com.gonnect.mocker.repository.MockRepository;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/admin/mocks")
@Api(tags = "Mock Administration")
@RequiredArgsConstructor
public class AdminController {

    private final MockRepository mockRepository;

    @GetMapping
    @ApiOperation("List all mock definitions")
    public List<MockDefinition> listMocks() {
        return mockRepository.findAll();
    }

    @GetMapping("/active")
    @ApiOperation("List active mock definitions")
    public List<MockDefinition> listActiveMocks() {
        return mockRepository.findByActiveTrue();
    }

    @GetMapping("/{id}")
    @ApiOperation("Get mock definition by ID")
    public ResponseEntity<MockDefinition> getMock(@PathVariable Long id) {
        return mockRepository.findById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    @ApiOperation("Create new mock definition")
    public MockDefinition createMock(@RequestBody MockDefinition mock) {
        return mockRepository.save(mock);
    }

    @PutMapping("/{id}")
    @ApiOperation("Update mock definition")
    public ResponseEntity<MockDefinition> updateMock(
            @PathVariable Long id,
            @RequestBody MockDefinition mock) {
        return mockRepository.findById(id)
            .map(existing -> {
                mock.setId(id);
                mock.setCreatedAt(existing.getCreatedAt());
                return ResponseEntity.ok(mockRepository.save(mock));
            })
            .orElse(ResponseEntity.notFound().build());
    }

    @DeleteMapping("/{id}")
    @ApiOperation("Delete mock definition")
    public ResponseEntity<Void> deleteMock(@PathVariable Long id) {
        if (mockRepository.existsById(id)) {
            mockRepository.deleteById(id);
            return ResponseEntity.noContent().build();
        }
        return ResponseEntity.notFound().build();
    }

    @PostMapping("/{id}/toggle")
    @ApiOperation("Toggle mock active status")
    public ResponseEntity<MockDefinition> toggleMock(@PathVariable Long id) {
        return mockRepository.findById(id)
            .map(mock -> {
                mock.setActive(!mock.isActive());
                return ResponseEntity.ok(mockRepository.save(mock));
            })
            .orElse(ResponseEntity.notFound().build());
    }
}

Dynamic Response Templates

Mocker supports Groovy-based response templates for dynamic response generation.

Template Interface

package com.gonnect.mocker.template;

public interface SimpleResponseTemplate {
    String transform(String requestBody, String responseTemplate);
}

public interface SmartResponseTemplate {
    String transform(Map<String, Object> request, String responseTemplate);
}

Groovy Template Example

// templates/DynamicResponseTemplate.groovy
package templates

import groovy.json.JsonSlurper
import groovy.json.JsonOutput

class DynamicResponseTemplate implements SimpleResponseTemplate {

    @Override
    String transform(String requestBody, String responseTemplate) {
        def jsonSlurper = new JsonSlurper()
        def request = jsonSlurper.parseText(requestBody)

        // Replace placeholders in response template
        def response = responseTemplate
            .replace('{{orderId}}', request.orderId ?: 'ORD-' + UUID.randomUUID())
            .replace('{{timestamp}}', System.currentTimeMillis().toString())
            .replace('{{status}}', calculateStatus(request))

        return response
    }

    private String calculateStatus(def request) {
        if (request.amount > 10000) {
            return 'PENDING_APPROVAL'
        }
        return 'CONFIRMED'
    }
}

Template Service

package com.gonnect.mocker.service;

import com.gonnect.mocker.template.SimpleResponseTemplate;
import groovy.lang.GroovyClassLoader;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.concurrent.ConcurrentHashMap;

@Slf4j
@Service
public class TemplateService {

    private final GroovyClassLoader groovyClassLoader = new GroovyClassLoader();
    private final ConcurrentHashMap<String, Class<?>> templateCache =
        new ConcurrentHashMap<>();

    public String applyTemplate(
            String templateClass,
            String requestBody,
            String responseTemplate) {

        try {
            Class<?> clazz = templateCache.computeIfAbsent(
                templateClass,
                this::loadTemplateClass);

            SimpleResponseTemplate template =
                (SimpleResponseTemplate) clazz.getDeclaredConstructor()
                    .newInstance();

            return template.transform(requestBody, responseTemplate);

        } catch (Exception e) {
            log.error("Template execution failed", e);
            return responseTemplate;
        }
    }

    private Class<?> loadTemplateClass(String className) {
        try {
            return groovyClassLoader.loadClass(className);
        } catch (Exception e) {
            throw new RuntimeException("Failed to load template: " + className, e);
        }
    }
}

Configuration

Application Configuration

# application.yml
server:
  port: 8080
  tomcat:
    max-threads: 200
    min-spare-threads: 10

spring:
  application:
    name: mocker
  datasource:
    url: jdbc:h2:mem:mockerdb
    driver-class-name: org.h2.Driver
  h2:
    console:
      enabled: true
      path: /h2-console
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: false

# Mocker Configuration
mocker:
  target:
    url: ${TARGET_SERVICE_URL:}
  record:
    enabled: true
  tcp:
    enabled: false
    port: 9999

Building and Running

Build

# Clone repository
git clone https://github.com/mgorav/Mocker.git
cd Mocker

# Build
mvn clean install

# Run
java -jar target/Mocker-*.jar

Docker

# Build image
docker build -t mocker:latest .

# Run with target service
docker run -p 8080:8080 \
  -e TARGET_SERVICE_URL=http://real-service.com \
  mocker:latest

API Usage Examples

Create a Mock

curl -X POST http://localhost:8080/admin/mocks \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Get User",
    "method": "GET",
    "path": "/api/users/123",
    "requestHash": "",
    "responseBody": "{\"id\": \"123\", \"name\": \"John Doe\", \"email\": \"john@example.com\"}",
    "responseStatus": 200,
    "contentType": "application/json",
    "active": true
  }'

Use the Mock

curl http://localhost:8080/mock/api/users/123

# Response:
# {"id": "123", "name": "John Doe", "email": "john@example.com"}

Access Swagger UI

Navigate to http://localhost:8080/swagger-ui.html for interactive API documentation.

Conclusion

The Mocker service virtualization platform provides:

  • Record & Replay: Automatically capture and replay service interactions
  • Protocol Support: REST, SOAP, and TCP mocking capabilities
  • Dynamic Templates: Groovy-based response transformation
  • Simple Administration: REST API and Swagger UI for mock management
  • Hash-Based Matching: Intelligent request deduplication

This enables:

  1. Parallel development without waiting for dependencies
  2. Consistent testing environments
  3. Edge case simulation
  4. Cost reduction for third-party API testing

Service virtualization is an essential tool for modern development teams building distributed systems.


References