In contrast to blocking concurrency model in which threads will be created in Threadpools and run load tests, Rhino yields reactive model in which test methods become specifications, that describe how a load test is to be executed in a declarative way rather than implementing what to run. With reactive approach and the DSL, the test developers do not necessarily need to deal with concurrency or HTTP client configuration. The framework materializes the DSL into reactive components and takes care of thread and connection management.

Similar to Java’s stream framework, the DSL consists of chained method calls. The DSL method, that is annotated with @Dsl annotation, returns a Load DSL instance:

@Simulation(name = "Reactive Test", durationInMins = 5)
@UserRepository(factory = OAuthUserRepositoryFactory.class)
public class ReactiveBasicHttpGetSimulation {

  @UserProvider
  private OAuthUserProvider userProvider;

  @Dsl(name = "Discovery")
  public DslBuilder singleTestDsl() {
    return dsl() 
        .run( 
            http("Discovery") 
            .header(c -> from(X_REQUEST_ID, "Rhino-" + userProvider.take()))
            .header(X_API_KEY, SimulationConfig.getApiKey())
            .auth()
            .endpoint(DISCOVERY_ENDPOINT)
            .get()
            .saveTo("result"));
  }
}

The specification can be created by using Rhino Load DSL which will be materialized by the framework. DSL methods starts with DSL builder ❶ which is followed by DSL expressions, the methods run the Specs ❷. DSL expressions accept load testing specifications like HttpSpec ❸ which will be materialized as reactive components in the load testing pipeline.

Writing your first DSL

Every DSL begins with DslBuilder.dsl() builder, that is followed by DSL expressions. A DSL expression is such that it is used to accept spec instances - that are passed to DSL expressions as parameters. DSL expressions can be chained together to build more complex DSL structures, e.g:

 dsl()
    .run(/*<some-spec>*/)
    .runIf(/*<some-spec>*/)
    .forEach(/*<some-spec>*/); /* more DSL expressions */

Before we dig into DSL expressions, let’s take a closer look at specs, first, which specifies actions to be performed.

Specs

Rhino provides two main spec types to test web services, the HttpSpec, that is used to describe the HTTP calls against services, and the SomeSpec, that allows developers to execute arbitrary code snippets, DSL expressions. DSL specs define the action itself like calling a web service or executing the code. You can extend the Rhino spec framework by adding custom specs which fit to your testing use cases. Before we take a deeper look at DSL expressions, let us first start with Specs:

Specs are instances describing the operation to be run by DSL expressions e.g HttpSpec specifies how a specific HTTP request would look like:

return dsl()
    .run(http("Files Request")
          .header(c -> from(X_REQUEST_ID, "Rhino-" + UUID.randomUUID().toString()))
          .header(X_API_KEY, SimulationConfig.getApiKey())
          .auth()
          .endpoint(FILES_ENDPOINT)
          .get()
          .saveTo("result"))

HttpSpec

HttpSpec describes how Http request looks like which will be passed to the DSL expressions. The spec begins with http(< measurement point >) and followed by chained builder methods. Measurement point is the identifier and used in reporting. It is the measurement name under which the measurement is recorded and should be unique.

http("Files Request")
    .header(X_API_KEY, SimulationConfig.getApiKey())
    .header(c -> from(X_REQUEST_ID, "Rhino-" + UUID.randomUUID().toString()))
    .auth()
    .endpoint(FILES_ENDPOINT)
    .get()
    .saveTo("result")

The header() method sets the request headers of the spec. There are two forms of header()-methods. The first one takes two parameters, the name of the header and the value of it. It is handy if you work with static values, that are not to be evaluated. However, sometimes you need to access user session to read some object out of the context, so the second form which takes a lambda with user session might be helpful in this case. Another reason why you might choose to use the lambda-form is whenever you need to access a provider instance:

http("Upload")
    .header(session -> from(X_REQUEST_ID, "Rhino-" + uuidProvider.take()))
    .header(X_API_KEY, SimulationConfig.getApiKey())
    .auth()
    .upload(() -> file("classpath:///test.txt"))
    .endpoint((c) -> FILES_ENDPOINT)
    .put()
    .saveTo("result")

Since the DSL-methods will be called only once at the beginning for materialization, if you need to use some objects out of providers and every time a new instance of that instance, you must use a lambda form in header() and endpoint() methods. The header will be then evaluated lazily in runtime, not as the materialization occurs. In lambda function, you can also access the session context, to access session objects or to put new objects to the session context. You can refer to Sessions for more about sessions.

auth() call enables authorization headers to be sent in the Http request which requires a repository of authorised users e.g @UserRepository(factory = OAuthUserRepositoryFactory.class) on the simulation. If your API does not require user authorization, you can omit the auth(). saveTo(“result”) call stores the response object in the context with the key “result” for the next specs in the chain.

SomeSpec

SomeSpec can be used to run arbitrary code in DSL expressions. SomeSpec is handy if you want to test something within the reactive pipeline, but you need to pay attention to that your code is not blocking so the pipeline does not get blocked.

@Dsl(name = "Random in memory file")
public DslBuilder testRandomFiles() {
  return dsl()
      .run(some("test").exec(s -> {
        return "OK";
      }));
  }

SomeSpec’s exec() DSL takes a lambda function which contains the code which is to be executed and returns a String object which describes the status of code execution, that will be used in reporting.

DSL Expressions

DSL expressions accept Spec instances like HttpSpec describing an HTTP request and materialises them into reactive components. They define how to run Spec instances which are passed to them. DSL expressions are used in chained method calls and they are run subsequently. Simple DSL expressions take only spec instances as parameters whereas more complex ones may take spec builders, that are helpers to build complex DSL expressions Let us take a closer look at the DSL expressions, first:

run

Most times, you will work with this DSL expressions. The run() method basically takes a spec. It accepts Spec instances as parameter:

run(http("Discovery")
    .header(c -> from(X_REQUEST_ID, "Rhino-" + userProvider.take()))
    .header(X_API_KEY, SimulationConfig.getApiKey())
    .auth()
    .endpoint(DISCOVERY_ENDPOINT)
    .get()
    .saveTo("result"))

The DSL expressions above executes HttpSpec discovery and stores the result of the HTTP request in the session context with the key “result”.

runIf

The runIf is a conditional DSL expressions as the run() DSL runs the spec with a conditional. If the conditional meets, then the Spec which is passed to the DSL expressions will be executed right away, otherwise it will be omitted:

return dsl()
.run(http("Upload text.txt")
    .header(session -> from(X_REQUEST_ID, "Rhino-" + userProvider.take()))
    .header(X_API_KEY, SimulationConfig.getApiKey())
    .auth()
    .endpoint(session -> UPLOAD_TARGET)
    .upload(() -> file("classpath:///test.txt"))
    .put()
    .saveTo("result"))
.runIf(session -> 
    session.<Response>get("result").map(r -> r.getStatusCode() == 200).orElse(false),
        http("Read File")
        .header(session -> from(X_REQUEST_ID, "Rhino-" + userProvider.take()))
        .header(X_API_KEY, SimulationConfig.getApiKey())
        .auth()
        .endpoint(session -> UPLOAD_TARGET)
        .get());

In the DSL above, the second DSL expressions will then be executed, if the first DSL expressions returns an HTTP 200. The first parameter to the DSL expressions is a predicate, a lambda which expects a parameter of UserSession (more about sessions, please refer to Sessions). The predicate above reads the status code out of session and if the result is HTTP 200 OK, then the “Read File” spec will be run.

wait

Wait DSL expressions holds the pipeline for the duration given:

dsl().wait(Duration.ofSeconds(1))

map

map() DSL expressions together with map builder is used to transform one DSL expression’s result into another object which might be used in the next DSL expressions. The builder expects a builder, first, to read the result object out of the session and then to map the result object into the new type:

dsl()
 .run(http("Files Request")
  .header(c -> from(X_REQUEST_ID, "Rhino-" + UUID.randomUUID().toString()))
  .header(X_API_KEY, SimulationConfig.getApiKey())
  .auth()
  .endpoint(FILES_ENDPOINT)
  .get()
  .saveTo("result"))
.map(MapperBuilder.<Response, Integer>
    from("result").doMap(response -> response.getStatusCode()))

In the example, we are mapping the HttpResponse object into Integer by calling getStatusCode()- method.

forEach

forEach-DSL is used to iterate over Iterable<T> instances, that are put in the user session by the preceding DSL expressions or passed as parameter directly to the expression. Let us take a look at the following in example in which we first store a list of files to upload into the session, and then we iterate over files list to upload the files, and fetch the metadata of each files uploaded:

  @Before
  public DslBuilder setUp() {
    return dsl()
        .session("files", FILES_TO_UPLOAD)
        .forEach(session("files"), this::uploadFile, "uploadedFiles")
        .forEach(session("files"), this::getMetadata, "metadata")
        .map(MapperBuilder.in(global("metadata"))
            .doMap(o -> ((HttpResponse)o).getResponseBodyAsString())
            .collect("ids", SessionDslItem.Scope.SIMULATION));
  }

  private HttpRetriableDsl getMetadata(Object response) {

     return http("GET metadata")
        .header(c -> headerValue(X_REQUEST_ID, "TwoUsersUploadDownloadSimulationTest-" + uuidProvider.take()))
        .auth()
        .endpoint(session -> FILES_ENDPOINT + "/" + response)
        .get();
  }

  private HttpRetriableDsl uploadFile(Object file) {

    return http("File upload")
        .header(session -> headerValue(X_REQUEST_ID, "TwoUsersUploadDownloadSimulationTest-" + uuidProvider.take()))
        .auth()
        .endpoint(session -> FILES_ENDPOINT + "/" + file)
        .upload(() -> file("classpath:///test.txt"))
        .put();
  }

}

The first argument of the forEach-expression is the Function<UserSession, R> iterableExtractor, that is a function takes a UserSession instance as argument and returns a Iterable instance over which the forEach will iterate through. The second argument is a function Function<E, T>, that is evaluated for each item of type E in the list and returns an instance of MaterializableDslItem, that is a DSL expression instance as methods’ uploadFile and getMetadata return types. And the last argument is the name of the session key, where the resulting objects need to be stored. The resulting objects will be kept in a list in the session and can be accessed by the following DSL expressions.

You can use ForEachBuilder for convenience instead of three parameter variant. The following one would do the same as above:

forEach(in(session("files")).doRun(this:: uploadFile).collect("uploadedFiles"));

Which translates into, read files items in session, do run for each the upload file, and collect the results in “uploadedFiles” session object. The resulting object in the sessions will be a java.lang.Iterable over which the next DSL expressions can iterate as map() expression does.

forEach()-expression can iterate on iterables directly without reading them out of session first:

forEach(ImmutableList.of("file1.jpg", "file2.jpg"), this:: uploadFile, "uploadFileResults");

runUntil

runUntil is a loop DSL which runs a Spec instance until the prediction holds:

@Dsl(name = "Upload File")
public DslBuilder singleTestDsl() {
return dsl()
  .runUntil(ifStatusCode(200),
    http("PUT Request")
      .header(c -> from(X_REQUEST_ID, "Rhino-" + uuidProvider.take()))
      .header(X_API_KEY, SimulationConfig.getApiKey())
      .auth()
      .upload(() -> file("classpath:///test.txt"))
      .endpoint((c) -> FILES_ENDPOINT)
      .put()
      .saveTo("result"))
    .run(http("GET on Files")
      .header(c -> from(X_REQUEST_ID, "Rhino-" + UUID.randomUUID().toString()))
      .header(X_API_KEY, SimulationConfig.getApiKey())
      .auth()
      .endpoint(FILES_ENDPOINT)
      .get().saveTo("result2"));
}

private Predicate<UserSession> ifStatusCode(int statusCode) {
  return session -> session.<Response> get("result")
    .map(Response::getStatusCode).orElse(-1) == statusCode;
}

runAsLongAs

runAsLongAs() - DSL expressions takes a Spec instance as long as the prediction holds:

@Dsl(name = "Upload File")
public DslBuilder singleTestDsl() {
  return dsl()
      .runAsLongAs(ifStatusCode(200),
        http("PUT Request")
          .header(c -> from(X_REQUEST_ID, "Rhino-" + uuidProvider.take()))
          .header(X_API_KEY, SimulationConfig.getApiKey())
          .auth()
          .upload(() -> file("classpath:///test.txt"))
          .endpoint((c) -> FILES_ENDPOINT)
          .put()
          .saveTo("result"));
    }

private Predicate<UserSession> ifStatusCode(int statusCode) {
  return s -> s.<Response>get("result").map(Response::getStatusCode).orElse(-1) == statusCode;
}

The first parameter to the DSL is the predicate which needs to hold

repeat

The DSL expressions repeats the execution of Spec infinitely:

@Dsl(name = "Upload File")
public DslBuilder singleTestDsl() {
  return dsl()
      .repeat(http("GET on Files")
        .header(c -> from(X_REQUEST_ID, "Rhino-" + UUID.randomUUID().toString()))
        .header(X_API_KEY, SimulationConfig.getApiKey())
        .auth()
        .endpoint(FILES_ENDPOINT)
        .get()
        .saveTo("result"));
  }

you can also provide with max repeats,

repeat(<spec>, 3)

ensure

The DSL expressions ensures the output of preceding DSL expressions by predicate. If the ensure does not succeed, the simulation will be terminated immediately:

@Dsl(name = "Upload File")
public DslBuilder singleTestDsl() {
  return dsl()
      .run(http("GET on Files")
        .header(X_API_KEY, SimulationConfig.getApiKey())
        .auth()
        .endpoint(FILES_ENDPOINT)
        .get()
        .saveTo("result2"))
        .ensure(s -> s.get("result").isPresent(), "No result object in session!");
  }

session

session() expression is used to store objects in user or simulation session. The objects from the session objects can be read and used in DSL expressions. Take the following example in which we first store the files to be uploaded into the session and then iterate over them in forEach() expression:

  private static final ImmutableList<String> FILES_TO_UPLOAD = ImmutableList.of(
      "a/",
      "a/b/",
      "a/b/c/");

  @Before
  public DslBuilder setUp() {
    return dsl()
        .session("files", FILES_TO_UPLOAD)
        .forEach(session("files"), this::uploadFile, "uploadedFiles")
        .forEach(session("files"), this::getMetadata, "metadata")
        .map(MapperBuilder.in(global("metadata"))
            .doMap(o -> ((HttpResponse)o).getResponseBodyAsString())
            .collect("ids", SessionDslItem.Scope.SIMULATION));
  }

Please do not confuse the session() DSL expression with the static helper method, session() from the SessionUtils class as it is used in the first argument of forEach-DSL, that is used to create functions to read the access session objects.

eval

Use eval to run arbitrary code snippets:

  @Dsl(name="test")
  public DslBuilder setUp() {
    return dsl()
        .session("index", () -> ImmutableList.of(1, 2, 3))
        .eval(session -> System.out.println(session.get("test").orElse("")));
  }

eval() execution will not be measured - nor reported.

measure

Beta: The custom measurement point DSL expression, measure() is in beta. It is discouraged to use nested measure() expressions more than 2 depth.

Defines a new measurement scope, that might contain multiple measurable DSL expressions of which processing time will be reported cumulatively. Let’s take a look at the following example:

  @Dsl(name = "Load DSL Discovery and GET")
  public DslBuilder loadTestDiscoverAndGet() {
    return dsl()
        .measure("Outer Measurement",
            dsl().run(discovery())
                .measure("Inner Measurement",
                    run(getResource())));
  }

After the first load generation cycle, discovery() and getResource() have been called once. The inner measurement records the same execution time as getResource does. The outer measurement aggregates both discovery and getResource execution time:

verify

verify() DSL expression is used to verify the outcome of a test execution whether it holds a particular assertion. The following DSL expression requires, that the HttpSpec ❶ needs to result in HTTP 201 ❷, otherwise the verification fails:

  @Dsl(name = "Load DSL Request")
  public DslBuilder singleTestDsl() {
    return dsl()
        .verify(http("Files Request")  
            .header(session -> headerValue(X_REQUEST_ID, "Rhino-" + UUID.randomUUID().toString()))
            .header(X_API_KEY, SimulationConfig.getApiKey())
            .auth()
            .endpoint(FILES_ENDPOINT)
            .get()
            .saveTo("result"),
          resulting("201")); 
  }

verify DSL expression takes a VerifiableDslItem e.g HttpSpec and a VerificationInfo instance as parameters. VerificationInfo contains a predicate, that is used in assertion verification and the description in case of verification fails. Verification tests need to be started by calling verify() - method:

    Simulation.getInstance(PROPERTIES_FILE, ReactiveBasicHttpGetSimulation.class).verify();

The verification results will be output in verification section of reporting: