Skip to content

Test Doubles

JohnBeaufoy edited this page Oct 10, 2018 · 6 revisions

Overview

Test Doubles are objects that stand in for a real object in a test, similar to how a stunt double stands in for an actor in a movie.

Test doubles include mocks, stubs and fakes, but essentially all have the goal of isolating the functionality for a given test. Where the object being tested has dependencies on other objects, we can substitute in Test doubles to minimise side effects and predefine their behaviour.

Writing test doubles by hand can be a cumbersome task, and can also lead to a proliferation of classes which can clutter our schemas and later be a hindrance for refactoring.

The mocking functionality in AutomatedTestSchema allows for test doubles to be created inline, which improves the readability and accuracy of the unit tests. Methods can be overridden to return predefined values, and also be checked they’ve actually been called.

Implementation

The following example shows how to create a test double to alter a sendEmail function. In this case we really don't want anyone being sent an email when performing a unit test - particularly a customer - so we override the default behavior to instead do nothing.

vars
	customer	: Customer;
	mocker		: ATMock;
	
begin
	create mocker transient;
	mocker.createClass(Customer);
	mocker.methodOverride(Customer::sendEmail).doNothing();
	
	customer := mocker.createTransient().Customer;
	customer.sendEmail();

In terms of whats going on under the hood, the first few lines create a transient class that inherits from Customer. A transient method is then created for the sendMail() function, which allows us to define new and temporary behaviour to execute instead while this object or another is being tested.

Method Overrides

The following functions are available on ATMockMethod to change the behaviour of a given method call:

  • doNothing() - ensures the normal implementation does not get called, returning null if required.
  • raisesException(errorCode, description) - raises an exception if the method is called.
  • returns(any) - when the method is called it will return the parameter type, which needs to be the same type as the method signature.
  • returnsMethodValue(receiver,method) - when the method is called, rather than execute the normal code it will instead call the param method on the param receiver.
  • returnsPropertyValue(receiver,property) - when the method is called, rather than execute the normal code it will instead return the property value as defined by the parameter.

Method call checks

The following functions are available on ATMock to test whether an overridden function has been called:

  • methodCalled(receiver,method): Boolean - returns true if the method is called once only on the receiver object.
  • methodCalledCount(receiver,method): Integer - returns the number of times the method is called on the receiver object.

Sample Schema

Deploy AutomatedTestSchema_DemoTestDoubles into your Jade environment to see a demonstration of this functionality.

The example uses a basic scenario for launching a missile with the following entities defined:

Class Missile
	fire();

Class LaunchCode
	code  : String;
	isValid(): Boolean;

Class LaunchControl
	launchMissile( missile : Missile; launchCode : LaunchCode );

The Missile and LaunchCode classes can be Unit Tested in isolation as they have no dependencies, so no mocking is required.

Writing Tests for the LaunchControl class takes some more work however, as it depends on the behaviour of the related objects.

Overriding the Launch code validation behaviour

Note in the following example how the default behaviour of returning false was replaced to return true.

vars
	launchCode		: LaunchCode;
	launchCodeMocker	: ATMock;
	
begin
	// default behaviour
	create launchCode transient;
	assertTrueMsg( "Launch code cannot be used", not launchCode.isValid() );
	
	// override behaviour to mimic a valid code by returning true instead
	create launchCodeMocker transient;
	launchCodeMocker.createClass( LaunchCode );
	launchCodeMocker.methodOverride( LaunchCode::isValid ).returns(true);		
	
	launchCode	:= launchCodeMocker.createTransient().LaunchCode;
	assertTrueMsg( "Launch code can be used", launchCode.isValid() );
end;

Override the Missile fire behaviour

Demonstrates how we can prevent the behaviour of a function call, while still proving its called.

vars
	missile		: Missile;
	missileMocker	: ATMock;
	
begin
	create missileMocker transient;
	missileMocker.createClass( Missile );
	missileMocker.methodOverride( Missile::fire ).doNothing();
	
	missile	:= missileMocker.createTransient().Missile;
	
	assertTrueMsg( "Missile::fire has not been called", 
				not missileMocker.methodCalled( missile, Missile::fire ));
	
	missile.fire();
	
	assertTrueMsg( "Missile::fire has been called", 
				missileMocker.methodCalled( missile, Missile::fire ));
end;

Test Launch a missile given a valid code

Make used of our Test doubles to do everything but fire the missile

vars
	launchCode		: LaunchCode;
	missile			: Missile;
	launchControl		: LaunchControl;
	fired			: Boolean;
	
	launchCodeMocker	: ATMock;
	missileMocker		: ATMock;
		
begin
	create launchCodeMocker transient;
	launchCodeMocker.createClass( LaunchCode );
	launchCodeMocker.methodOverride( LaunchCode::isValid ).returns(true);
	launchCode	:= launchCodeMocker.createTransient().LaunchCode;	

	create missileMocker transient;
	missileMocker.createClass( Missile );
	missileMocker.methodOverride( Missile::fire ).doNothing();
	missile	:= missileMocker.createTransient().Missile;
	
	create launchControl transient;
	fired	:= launchControl.launchMissile( missile, launchCode );
	
	// asserts
	assertTrueMsg( "Missile fired", fired );
end;

Test Launch fails given an invalid code

Test that the launch is aborted if the Launch code is invalid

vars
	launchCode	: LaunchCode;
	missile		: Missile;
	launchControl	: LaunchControl;
	fired		: Boolean;

	missileMocker	: ATMock;
		
begin
	create launchCode transient;

	create missileMocker transient;
	missileMocker.createClass( Missile );
	missileMocker.methodOverride( Missile::fire ).doNothing();
	missile	:= missileMocker.createTransient().Missile;
	
	create launchControl transient;
	fired	:= launchControl.launchMissile( missile, launchCode );
	
	// asserts
	assertTrueMsg( "Missile did not fire", fired = false );
	assertTrueMsg( "Missile::fire has not been called", 
		not missileMocker.methodCalled( missile, Missile::fire ));
end;