Basic Concepts and Usage of Mock
Creating a Mock Object
The mock constructor can create mock and spy objects by calling and functions mock<T> and spy<T>, where T represents the mocked class or interface.
public func mock<T>(): T
public func spy<T>(objectToSpyOn: T): T
As a skeleton object, mock does not perform any operation on members by default. As a special mock object, spy is used to wrap the current instance of a class or interface. By default, calls to members of a spy object are entrusted to an underlying object. In other aspects, the spy object is similar to the mock object.
Only classes (including the final class and sealed class) and interfaces can be mocked.
For details, see Use of Mock and Spy Objects. See Top-Level and Static Declarations for how to mock top and static declarations.
Configuration API
Configuration API is the core of the framework. It can define the behavior of a mock or spy object (or the top-level or static declaration) or redefine a spy object (or the top-level or static declaration).
The entry of configuration API is the @On macro call.
@On(storage.getComments(testId)).returns(testComments)
In this example, if the mock object storage receives a call to the getComments method and the parameter testId is specified, testComment is returned.
The preceding behavior is called stubbing. Stubs need to be first defined within the principal part of a test case, where a stub simulates a component that is not implemented or cannot be executed in the test environment.
The following declaration types can be stubbed:
- Instance members of classes and interfaces (including the final members)
- Static functions, attributes, and fields
- Top-level functions and variables
The following declarations cannot be stubbed:
- Extended members
- Foreign functions
- Local functions and variables
- Constructors
- Constants
- Any private declarations
A complete stub declaration consists of the following parts:
- Stub signature described in the
@Onmacro call; - operation used to describe the stub behavior;
- (Optional) Cardinality specifier (an expression specifying the expected number of executions) used to set expectations; and
- (Optional) continuation (an expression supporting chain calls).
The mock framework intercepts the call that matches the stub signature and performs the operation specified in the stub declaration.
Top-Level and Static Declarations
Unlike a class or interface member, a mock object does not need to be created to stub a static member or a top-level function or variable. These declarations should directly use the configuration API (for example, the @On macro) for stubbing.
The following is an example of stubbing a top-level function:
package catalog
public class Entry {
init(let id: Int64, let title: String, let description: String) {}
statuc func parse() { ... }
}
public func loadLastEntryInCatalog(): Entry {
let resp = client.get("http://mycatalog.com/api/entries/last")
let buf = Array<UInt8>(50, repeat: 0)
let len = resp.body.read(buf)
return Entry.parse(String.fromUtf8(buf[..len]))
}
public func drawLastEntryWidget() {
let lastEntry = loadLastEntryInCatalog()
// drawing...
}
package test
@Test
class RightsTest {
@TestCase
func removeLastEntry() {
@On(loadLastEntryInCatalog()).returns(Entry(1, "Test entry", "Test description"))
drawLastEntryWidget()
}
}
Stub Signature
A stub signature defines a set of conditions that match a specified subset of calls, including the following parts:
- A single identifier must be used for reference to a mock or spy object. (This part is not required for independent declarations (top-level or static functions and variables).)
- Call of members and independent declarations.
- Call of parameters in specified formats. For details, see Parameter Matcher.
A signature can match the following entities:
- Methods
- Attribute getter
- Attribute setter
- Field read operation
- Field write operation
- Static functions
- Static attribute getter
- Static attribute setter
- Static field read operation
- Static field write operation
- Top-level functions
- Top-level field read operation
- Top-level field write operation
As long as the corresponding declaration is called and all parameters (if any) match the corresponding parameter matcher, the stub signature matches the call.
The signature structure of a method stub is <mock object name>.<method name>(<argument matcher>*).
@On(foo.method(0, 1)) // Call a method with the parameter of 0 and 1
@On(foo.method(param1: 0, param2: 1)) // Call a method with named parameters
When a stub signature matches an attribute getter/setter or a field read/write operation, <mock object name>.<property or field name> or <mock object name>.<property or field name> = <argument matcher> is used.
@On(foo.prop) //Attribute getter
@On(foo.prop = 3) //Attribute setter with the parameter of 3
Top-level functions and static functions have similar signatures:
- Top-level functions:
<function name>(<argument matcher>*) - Static functions:
<class name>.<static method name>(<argument matcher>*)
The signatures of top-level variables and static attributes or fields are as follows:
- Top-level variables:
<top-level variable name>or<top-level variable name> = <argument matcher> - Static attributes or fields:
<class name>.<static property/field name>or<class name>.<static property/field name> = <argument matcher>
To stub an operator function, the recipient of the operator must be a single reference to the mock/spy object, and the parameter of the operator must be a parameter matcher.
@On(foo + 3) // 'operator func +', with the parameter of 3
@On(foo[0]) // 'operator func []', with the parameter of 0
Parameter Matcher
Each stub signature must contain the parameter matchers for all parameters. A single parameter matcher defines a condition for accepting a subset of all possible parameter values. Each matcher is defined by calling a static method of the Matchers class. For example, Matchers.any() is a valid matcher that permits any parameter. For convenience, a syntactic sugar without the Matcher. prefix is provided.
The predefined matchers include:
| Matcher | Description | Syntactic sugar |
|---|---|---|
| any() | Any parameter | Symbol _ |
eq(value: Equatable) | Parameters with the same value structure (structural equality, meaning that objects have the same value but possibly different memory) | A single identifier and literal constants are allowed. |
same(reference: Object) | Parameters with the same reference (referential equality, meaning that objects have equal reference and same memory) | A single identifier is allowed. |
ofType<T>() | Values of the T type | - |
argThat(predicate: (T) -> Bool) | Values of the T type obtained by predicate | - |
| none() | None value of the option type | - |
If a single identifier is used as the parameter matcher, the parameters with the same structure are preferred.
If a method has a default parameter and the parameter is not explicitly specified, the any() matcher is used.
Examples:
let p = mock<Printer>() //Assume that print adopts a single parameter of the ToString type.
@On(p.print(_)) //The special character "_" can be used to replace any().
@On(p.print(eq("foo"))) //Match the foo parameter only.
@On(p.print("foo")) //The explicit matcher can be omitted for constant strings.
let predefined = "foo" //A single identifier, instead of a parameter matcher, can be passed.
@On(p.print(predefined)) //For the same type, structural equality is used for matching.
@On(p.print(ofType<Bar>())) //Only parameters of the Bar type are matched.
//For more complex matchers, the following mode is recommended.
let hasQuestionMark = { arg: String => arg.contains("?") }
@On(p.print(argThat(hasQuestionMark))) //Only strings with a question mark are matched.
Selecting function overloading properly depends on the type inference mechanism of Cangjie. ofType can be used to solve compilation problems related to type inference.
Note: When used as a parameter matcher, a function call is considered a call to the matcher.
@On(foo.bar(calculateArgument())) //Incorrect. calculateArgument() is not a matcher.
let expectedArgument = calculateArgument()
@On(foo.bar(expectedArgument)) //Correct as long as expectedArgument is equivalent and/or of the reference type.
Operation API
The mock framework provides APIs to specify stub operations. After the stub is triggered, the stub declaration performs the specified operation. If the call matches the signature specified by the corresponding @On macro call, the stub is triggered.
Each stub function must specify an operation. The ActionSelector subtype returned by the @On macro call defines available operations. The list of operations depends on the entity to be stubbed.
Universal (Operations)
Specifies the operations applicable to all stubs.
throws(exception: Exception):exceptionis thrown.throws(exceptionFactory: () -> Exception):exceptionFactoryis called to construct an exception thrown when a stub is triggered.fails(): If a stub is triggered, the test fails.
NOTE
throwsis used to test the system behavior when the stub declaration throws an exception.failsis used to check whether the stub declaration is not called.
@On(service.request()).throws(TimeoutException())
Function and Attribute/Field Getter and Top-Level Variable Read Operation
R indicates the return type of a member.
returns(): No operation is performed and()is returned. This return type is available only whenRisUnit.returns(value: R):valueis returned.returns(valueFactory: () -> R):valueFactoryis called to construct an exception thrown when a stub is triggered.returnsConsecutively(values: Array<R>),returnsConsecutively(values: ArrayList<R>): When a stub is triggered, the next element invaluesis returned.
@On(foo.bar()).returns(2) //0 is returned.
@On(foo.bar()).returnsConsecutively(1, 2, 3) //1, 2, and 3 are returned consecutively.
Attribute/Field Setter and Top-Level Variable Write Operation
doesNothing(): Calls are ignored and no operation is performed. It is similar toreturns()the function that returns Unit. For more details, see Here.
Spy Operation
For spy objects, other operations can be used to entrust monitoring instances.
callsOriginal(): Calls an original method.getsOriginal(): Calls an original attribute getter or obtains the field value from an original instance.setsOriginal(): Calls an original attribute setter or sets the field value in an original instance.
Expectation
When a stub is defined, an expectation is implicitly or explicitly attached to the stub. A stub can define the desired cardinality. An instance of CardinalitySelector is returned after an operation (except fails and returnsConcesecutively). Expectations can be customized for this instance by using a cardinality specifier.
CardinalitySelector defines the following functions:
once()atLeastOnce()anyTimes()times(expectedTimes: Int64)times(min!: Int64, max!: Int64)atLeastTimes(minTimesExpected: Int64)
The anyTimes specifier is used to improve expectations, that is, if a stub is never triggered, the test will not fail. Other specifiers imply the upper and lower limits on the number of times a specified stub is called in the test code. If a stub is triggered more than expected, the test fails immediately. The lower limit is checked after the test code is executed.
Examples:
// example_test.cj
@Test
func tooFewInvocations() {
let foo = mock<Foo>()
@On(foo.bar()).returns().times(2)
foo.bar()
}
Output:
Expectation failed
Too few invocations for stub foo.bar() declared at example_test.cj:9.
Required: exactly 2 times
Actual: 1
Invocations handled by this stub occurred at:
example_test.cj:6
If no expectation is customized, the mock framework adopts the default expectation.
| Operation | Default Expected Cardinality | Custom Cardinality Allowed or Not |
|---|---|---|
| fails | Cannot be called. | No |
| returns | atLeastOnce | Yes |
| returnsConsecutively | times(values.size) | No |
| throws | atLeastOnce | Yes |
| doesNothing | atLeastOnce | Yes |
| (calls/gets/sets)Original | atLeastOnce | Yes |
Stub Chain
For the returnsConsecutively operation, the once and times(n) cardinality specifiers return a continuation instance. As the name implies, continuation means that continued use of a chain is allowed. Specifically, an operation is executed immediately after the previous operation is completed.
The continuation itself only provides the then() function that returns a new ActionSelector. Same rules apply to all operations on the chain. If then() is called, a new operation must be specified.
The total number of expectations is the sum of expectations of all chains. If a complex chain is specified in a test, all parts of the chain are triggered.
@On(foo.bar()).returnsConsecutively(1, 2, 3, 4)
//Same as below.
@On(foo.bar()).returnsConsecutively(1, 2).then().returnsConsecutively(3, 4)
//A stub is specified and must be called for NUM_OF_RETRIES times in total.
@On(service.request()).throws(TimeoutException()).times(NUM_OF_RETRIES - 1). //Request timeout occurs for several times.
then().returns(response).once() //Sending the request stops after the first successful response is received.
Use of Spy and Mock Objects
Spy objects and mock objects are similar in configuration, except that the spy objects monitor current instances.
The main difference is as follows: When a member call does not trigger any stub, the spy object calls the original implementation of the underlying instance, and the mock object throws a runtime error (the test fails).
Mock objects do not need to create real dependencies to test APIs, but configure the behavior required for a specific test scenario.
Spy objects allow you to rewrite the observable behavior of real instances. Only the calls referenced by spy objects are intercepted. Creating a spy object does not affect the reference of the original spy instance. Calling a method of the spy itself is not intercepted.
let serviceSpy = spy(service)
//Simulation timeout. Continue to use a real implementation.
@On(serviceSpy.request()).throws(TimeoutException()).once().then().callsOriginal()
//Test code must use 'serviceSpy' reference.
NOTE
The stub behavior of a static member or top-level function/variable is similar to that of a spy object. That is, for a declaration that is not stubbed, the original member or top-level function/variable is called instead of throwing an exception like a mock object.
Mock dependency
Interfaces can always be mocked. When a class is mocked from another package, the class itself and its superclass must be compiled in a specified way. That is, only the interfaces in precompiled libraries (for example, stdlib), rather than classes, can be mocked.
Compilation Using CJC
For CJC, mocks are controlled by the --mock flag. To mock the class p in a specified package, add the --mock=on flag to CJC for calling.
This flag must also be added when a package dependent on
pis compiled.
An extra flag is not needed for using a mock object (cjc--test) in tests.
Compilation Using CJPM
CJPM automatically detects the use of mocks and generates correct CJC calls to ensure that classes can be mocked from any package compiled using source code.
In addition, the CJPM configuration file can be used to control the packages to support mocks.