Testing

For testing, a special actor system called TestSystem provides an additional testing feature. It allows intercepting outgoing messages of the actor to be tested. Therefore, an actor’s behavior can be tested (black box testing). How does the actor react to an ingoing message? Each actor, which should be tested, must implement the interface ActorTest. This interface defines a method called test, which returns a list of predefined testing stories [1] for that actor. The testing stories of this actor can then be executed by the TestSystem. The testing stories are derived from behavior-driven development (BDD).

<dependency>
	<groupId>io.actor4j</groupId>
	<artifactId>actor4j-testing</artifactId>
	<version>${actor4j.version}</version>
	<scope>test</scope>
</dependency>

Example

The following testing story will send a message “Hello World!” to the actor itself. The actor will respond to this message with “Hello World Again!”. The testing actor system can intercept this message (see method test).

public class MyActor extends Actor implements ActorTest {
	@Override
	public void receive(ActorMessage<?> message) {
		if (message.value() instanceof String) {
			logger().info(String.format(
				"Received String message: %s", message.valueAsString()));
			tell("Hello World Again!", 0, message.source());
		} 
		else
			unhandled(message);
	}
	
	@Override
	public List<Story> test() {
		TestSystem system = (TestSystem)getSystem();
		AtomicReference<String> actual = new AtomicReference<>();
		
		Story story = new Story();
		story.scenario()
			.annotate("Scenario: Sending and receiving a message String")
			.annotate("Given a request message")
			.given(() -> send(ActorMessage.create("Hello World!", 0, system.SYSTEM_ID(), self())))
			.annotate("When the responded message was received")
			.when(() -> {
				try {
					actual.set(system.awaitMessage(5, TimeUnit.SECONDS).valueAsString());
				} catch (InterruptedException | TimeoutException e) {
					throw new AssertionError("No response", e);
				}
			})
			.annotate("Then the responded received String message will be \"Hello World Again!\"")
			.then(() -> outcome(actual.get()).shouldBe("Hello World Again!"))
			.and(() -> system.assertNoMessages(200, TimeUnit.MILLISECONDS));
		
		return List.of(story);
	}
}

The stories are executed within a JUnit test:

public class MyActorFeature {
	protected TestSystem system;
	
	@Before
	public void before() {
		system = TestSystem.create();
		system.addActor(() -> new MyActor());
		system.start();
	}
	
	@After
	public void after() {
		system.shutdownWithActors(true);
	}
	
	@Test(timeout=5000)
	public void test() {
		system.testAllActors();
	}
}
/* Full examples under https://github.com/relvaner/actor4j/tree/master/actor4j-examples */

testAllActors() executes the stories of every actor that implements ActorTest, one actor after the other. testActor(id) executes only the stories of one actor. A failing step fails the JUnit test. All stories are still executed; the first failure is thrown, further failures are attached as suppressed exceptions.

Intercepted messages

While an actor is under test, every message it sends to another actor is redirected to an internal pseudo actor. The steps fetch these messages with awaitMessage, in the order they were sent.

  • Intercepted: tell/send by id or alias, priority, unsafe sends — everything the actor under test sends to someone else.
  • Delivered as usual: messages to the actor under test itself (e.g. the request in the example, sent from SYSTEM_ID), all messages between other actors, and directives such as STOP or RESTART, also when the actor under test triggers them (supervision of its children keeps working).
  • An alias send of the actor under test to its own alias is intercepted as well.

The redirection ends as soon as the stories of the actor are finished, also when a step throws.

Checking for no messages

Method Meaning Use when
assertNoMessages() snapshot: no message is there right now the last expected message was already awaited, so nothing can still be on its way (e.g. the actor sends a final done message), or at the start of a story
assertNoMessages(timeout, unit) no message arrives within the timeout you want to show that the actor does not answer, or does not send anything more

Neither method consumes a message: a message found by them can still be fetched with awaitMessage.

Threading

The stories are executed on the caller’s thread (the JUnit thread), not on the actor’s thread. This is necessary, because a step that awaits a response would otherwise block the actor itself. As a consequence, steps should send messages and check messages, and should not read or write fields of the actor directly; the actor processes messages on its own thread at the same time. To check the internal state, let the actor answer a state query. The response is intercepted like any other message:

public class CounterActor extends Actor implements ActorTest {
	public static final int INCREMENT = 1;
	public static final int STATE = 2;
	
	protected long counter;
	
	@Override
	public void receive(ActorMessage<?> message) {
		if (message.tag()==INCREMENT)
			counter++;
		else if (message.tag()==STATE)
			tell(counter, STATE, message.source());
	}
	
	@Override
	public List<Story> test() {
		TestSystem system = (TestSystem)getSystem();
		
		Story story = new Story();
		story.scenario()
			.given(() -> {
				for (int i=0; i<3; i++)
					send(ActorMessage.create(null, INCREMENT, system.SYSTEM_ID(), self()));
			})
			.when(() -> send(ActorMessage.create(null, STATE, system.SYSTEM_ID(), self())))
			.then(() -> {
				try {
					outcome(system.awaitMessage(5, TimeUnit.SECONDS).value()).shouldBe(3L);
				} catch (InterruptedException | TimeoutException e) {
					throw new AssertionError("No state", e);
				}
			});
		
		return List.of(story);
	}
}

If a field must be read directly, declare it volatile or use an Atomic* type.

Limitations

  • Scenario keeps the current example in static fields, so stories must not be executed in parallel (e.g. parallel JUnit execution with several TestSystem instances in one JVM).
  • awaitMessage returns the next intercepted message; there is no filter by tag or sender yet.