How to Build a Dynamic Dubbo Mock Service Factory for Seamless Testing

This article analyzes the challenges of mocking Dubbo services in large‑scale Java projects, explores multiple design options—including generic service registration, Javassist proxies, and custom reference implementations—compares their pros and cons, and presents the final architecture that enables dynamic registration, method‑level mocking, and transparent fallback to real services.

Youzan Coder
Youzan Coder
Youzan Coder
How to Build a Dynamic Dubbo Mock Service Factory for Seamless Testing

Background

Dubbo is widely used for Java RPC services. In daily development developers often need to mock services for unit testing or integration testing. Dubbo provides a generic service registration entry and a generic invocation entry, but using them directly leads to three practical problems:

When the provider and consumer share a public registry, the consumer cannot guarantee that it discovers the mock service.

Consumers cannot modify code to connect to a mock service by IP+PORT.

Using a private registry solves the discovery issue, but the mock granularity is at the method level; a service that contains both mocked and non‑mocked methods must be fully mocked.

To address these issues a Mock Factory was designed to register mock Dubbo services dynamically, support multiple services on a single server, and transparently forward non‑mock methods to the original service.

Design Overview

Service Chain Routing

A Service Chain identifier is attached to the request at the source and propagated through all cross‑application remote calls. The routing rules are:

If the request carries a Service Chain ID, it is routed only to nodes belonging to that chain.

If the request does not carry a Service Chain ID, all chain nodes are excluded.

If the request carries a Service Chain ID but the current application belongs to a different chain, a routing exception is thrown.

This mechanism isolates a logical chain of instances while allowing unchanged services to be shared.

Mock Service Implementation

Two implementation strategies were evaluated:

Option 1 – GenericService : implement GenericService and register it with the generic=true flag. The implementation only needs to provide $invoke(String method, String[] paramTypes, Object[] args). It is simple but conflicts with the existing service discovery because the registry stores both generic and normal entries and the normal service is selected first.

Option 2 – Javassist Proxy : generate a proxy class for the target interface using Javassist, register the proxy as a normal Dubbo service, and mark it as generic. This approach automatically creates a concrete implementation, guarantees return‑type safety, and forwards non‑mocked methods to the original service. The drawback is the lack of priority selection and the difficulty of debugging generated bytecode.

Considering platform requirements (minimal user operation and compatibility with the existing architecture), Option 2 was chosen.

Dynamic Registration with ServiceConfig

Dubbo exposes a service through the following simplified sequence:

Spring parses dubbo:service and creates a ServiceBean definition. ServiceConfig obtains the implementation instance (the proxy generated by Javassist) and creates an AbstractProxyInvoker via ProxyFactory.getInvoker.

The invoker is wrapped into an Exporter, which finally converts the service into a URL and registers it to the registry (ETCD).

During registration the Service Chain name is stored in the service parameters so that the routing filter can pick the correct mock instance.

void register(Object impl, String serviceChain) {
    ServiceConfig<Object> config = new ServiceConfig<>();
    config.setInterface(impl.getClass().getInterfaces()[0]);
    config.setRef(impl);
    config.setFilter("request");
    config.getParameters().put(Constants.SERVICE_CONFIG_PARAMETER_SERVICE_CHAIN_NAME, serviceChain);
    config.export();
    if (config.isExported()) {
        log.warn("Publish success: {}-{}", serviceChain, config.getInterface());
    } else {
        log.error("Publish failed: {}-{}", serviceChain, config.getInterface());
    }
}

Javassist Proxy Generation

The proxy class is built from the target interface. Special handling is required for primitive types because Javassist works with CtClass objects. An enum‑based helper maps primitive names to the corresponding CtClass constants:

private static CtClass getParamType(ClassPool pool, String paramType) {
    switch (paramType) {
        case "char":   return CtClass.charType;
        case "byte":   return CtClass.byteType;
        case "short":  return CtClass.shortType;
        case "int":    return CtClass.intType;
        case "long":   return CtClass.longType;
        case "float":  return CtClass.floatType;
        case "double": return CtClass.doubleType;
        case "boolean":return CtClass.booleanType;
        default:        return pool.get(paramType);
    }
}

Method signatures are resolved at runtime, and the generated proxy forwards calls to a ServiceReference when a custom implementation is supplied, otherwise it returns the predefined mock value.

Transparent Pass‑Through for Non‑Mock Methods

When a method is not defined in the mock map, the proxy forwards the call to the original (base) service using Dubbo’s generic invocation. The generic result may contain a class field that must be stripped before deserialization:

Object result = genericService.$invoke(method, paramTypes, args);
if (result instanceof HashMap) {
    Class dtoClass = Class.forName(((HashMap) result).get("class").toString());
    ((HashMap) result).remove("class");
    String json = JSON.toJSONString(result);
    return JSON.parseObject(json, dtoClass);
}
return result;

Request Recording and Caching

A custom filter is added to each mock service (via ServiceConfig.filter="request") to capture the interface name, method name, parameters, response, and latency. The captured data is stored in a two‑level Guava cache: the first level keys by interface, the second level keys by method and keeps up to ten recent requests per method. The cache expires after seven days of inactivity.

@Singleton(lazy = true)
class CacheUtil {
    private static final Object PRESENT = new Object();
    private final Cache<String, Cache<String, RequestDO, Object>> caches =
        CacheBuilder.newBuilder()
            .maximumSize(10000)
            .expireAfterAccess(7, TimeUnit.DAYS)
            .build();
}

ClassLoader Management for External JARs

Mock services may need to load API definitions from external JARs at runtime. Three approaches were tried:

Creating a dedicated URLClassLoader per JAR and switching the thread context class loader – ineffective because Dubbo’s Wrapper always uses the application class loader.

Using a custom TestPlatformClassLoader that maps JARs to their loaders – cannot be set via -Djava.system.class.loader because the class does not exist at JVM start‑up.

Launching the application inside a custom container thread: a new thread is created, its context class loader is set to a TestPlatformClassLoader, and the main class is executed inside that thread. This isolates the custom loader from the system loader.

The final solution combines all three: each external JAR gets its own URLClassLoader, the mapping is kept in TestPlatformClassLoader, and the whole mock platform runs inside the container thread.

When regenerating a proxy class with the same name, a SecureClassLoader is created for each generation to avoid the duplicate class definition error:

ClassLoader loader = new SecureClassLoader(parent);
Class<?> clazz = ctClass.toClass(loader);

ReferenceConfig Initialization Fix

Dubbo’s ReferenceConfig sets initialize=true by default. If the target service is not yet started, the first generic call fails and the reference remains invalid, causing all subsequent calls to fail. The fix is to create a fresh ReferenceConfig for each attempt and store the GenericService only when the call succeeds:

synchronized (hasInit) {
    if (!hasInit) {
        ReferenceConfig<GenericService> rc = new ReferenceConfig<>();
        rc.setInterface(serviceInfoDO.interfaceName);
        rc.setApplication(serviceInfoDO.applicationConfig);
        if (serviceInfoDO.version != null && !serviceInfoDO.version.isEmpty()) {
            rc.setVersion(serviceInfoDO.version);
        }
        if (serviceInfoDO.refUrl == null || serviceInfoDO.refUrl.isEmpty()) {
            throw new NullPointerException("refUrl must not be empty");
        }
        rc.setUrl(serviceInfoDO.refUrl);
        rc.setGeneric(true);
        GenericService gs = rc.get();
        if (gs != null) {
            genericService = gs;
            hasInit = true;
        }
    }
}

Supported Scenarios

Scenario 1 – Normal Call : Consumer discovers only the base service from ETCD and invokes it directly.

Scenario 2 – JSON Mock : Consumer discovers both base and mock services; the Service Chain routes the request to the mock service, which returns predefined JSON data.

Scenario 3 – Default Pass‑Through : Mock service does not implement the requested method, so the request is transparently forwarded to the base service.

Scenario 4 – Custom Service (CF) : Mock service delegates the call to a user‑provided Dubbo service (custom reference) and returns the custom result.

Scenario 5 – Nested Calls : A mocked method calls another service; the Service Chain identifier is propagated, causing the downstream call to be routed to its corresponding mock service.

Scenario 6 – Existing Service Chain Replacement : When a Service Chain service already exists, it is switched to subscriber‑only mode and the new mock service’s pass‑through address points to the original service, ensuring only one Service Chain service is discoverable.

Key Terminology

Consumer : initiates a Dubbo request.

Base Service : normal service without Service Chain tags.

Mock Service : service generated by the Mock Factory.

ETCD : service registry holding both Base and Mock services.

Default Pass‑Through : non‑mock methods are invoked on the Base service.

Custom Service (CF) : user‑provided generic Dubbo service that does not require registration.

Service Chain Diagram
Service Chain Diagram
Option 1 – GenericService
Option 1 – GenericService
Option 2 – Javassist Proxy
Option 2 – Javassist Proxy
Mock Factory Architecture
Mock Factory Architecture
Mocker Container
Mocker Container
Third‑Party JAR Management
Third‑Party JAR Management
Service Registration Sequence
Service Registration Sequence
Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Javaservice discoveryDubboetcdJavassistService ChainGeneric Servicemock service
Youzan Coder
Written by

Youzan Coder

Official Youzan tech channel, delivering technical insights and occasional daily updates from the Youzan tech team.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.