-
Notifications
You must be signed in to change notification settings - Fork 2
Test Doubles
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.
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.
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.
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.
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.
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;
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;
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 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;