Skip to content

Latest commit

 

History

History
596 lines (434 loc) · 22.4 KB

README.md

File metadata and controls

596 lines (434 loc) · 22.4 KB

main release

Lightweight mocking library in Apex

This project provide a simple, lightweight, easy to read, fully tested mocking library for apex built using the Apex Stub API. We want its usage to be simple, its maintainability to be easy and to provide the best developer experience possible

Table of Contents

Principles

APIs design come from our experience with Mockito, chai.js, sinon.js and jest. The library aims to provide developers a simple way to stub, mock, spy and assert their implementations. Dependency Injection and Inversion Of Control are key architectural concepts the system under test should implements

Why you should use the library

It helps you isolate the code from its dependency in unit test. Using the library to mock your classes dependencies will contribute to improve code quality and maintanibility of your project.

It helps you write unit test by driving the behavior of the class dependencies (instead of relying on it by integration). Using the library to mock DML and SOQL from your tests will help you save a lot of time in apex test execution (the tests will not sollicitate the database anymore).

Installation

Deploy via the deploy button

Deploy to Salesforce

Or copy force-app/src/classes apex classes in your sfdx project to deploy it with your favourite deployment methods

Or you can install the library using our unlocked package without namespace from the latest release

Namespaced Org /!\

It's not possible to install a non namespaced unlocked package into a namespaced org.

In this case you have those choices:

  • Install from sources (with or without manually prefixing classes)
  • Create your own unlocked/2GP package with your namespace containing the sources

It's not recommended for a 2GP package to depends on an unlocked package, namespaced or not (ISV scenario).

Usage

Mock

To mock an instance, use the Mock.forType method It returns a Mock instance containing the stub and all the mechanism to spy/configure/assert

Mock myMock = Mock.forType(MyType.class);

How to stub namespaced type?

Because Test.createStub() call cannot cross namespace, we provide a StubBuilder interface to stub type from your namespace. Create a StubBuilder implementation in your namespace (it must be the same implementation as the Mock.DefaultStubBuilder implementation but has to be in your namespace to build type from your namespace).

Mock myMock = Mock.forType(MyType.class, new MyNamespace.MyStubBuilder());

Stub

Use the stub attribute to access the stub,

MyType myTypeStub = (MyType) myMock.stub;
MyService myServiceInstance = new MyServiceImpl(myTypeStub);

Spy

Use the spyOn method from the mock to spy on a method, It returns a MethodSpy instance containing all the tools to drive its behaviour and spy on it

MethodSpy myMethodSpy = myMock.spyOn('myMethod');

How to Configure a spy

Default behaviour

By default, a spy return null when called, whatever the parameters received.

// Act
Object result = myTypeStub.myMethod();
// Assert
Assert.areEqual(null, result);

Have a look at the NoConfiguration recipe

Global returns

Configure it to return a specific value, whatever the parameter received The stub will always return the configured value

// Arrange
myMethodSpy.returns(new Account(Name='Test'));
// Act
Object result = myTypeStub.myMethod();
// Assert
Assert.areEqual(new Account(Name='Test'), result);

Have a look at the Returns recipe

You can also configure it to return the same object multipe times

// Arrange
myMethodSpy.returns(new Account(Name='Test'), 3);
for(Integer i = 0 ; i < 3 ; ++i) {
  // Act
  Object result = myTypeStub.myMethod();
  // Assert
  Assert.areEqual(new Account(Name='Test'), result);
}
Global returns once

Configure it to return a specific value once, whatever the parameter received The stub will return the configured value once

// Arrange
myMethodSpy.returnsOnce(new Account(Name='Test'));
// Act
Object result = myTypeStub.myMethod();
// Assert
Assert.areEqual(new Account(Name='Test'), result);

Have a look at the ReturnsOnce recipe

Global throws

Configure it to throw a specific exception, whatever the parameter received The stub will always throw the configured exception

// Arrange
myMethodSpy.throwsException(new MyException());
try {
    // Act
    Object result = myTypeStub.myMethod();

    // Assert
    Assert.fail('Expected exception was not thrown');
} catch (Exception ex) {
    Assert.isInstanceOfType(ex, MyException.class);
}

Have a look at the Throws recipe

You can also configure it to throw the same exception multipe times

// Arrange
myMethodSpy.throwsException(new MyException(), 3);
for(Integer i = 0 ; i < 3 ; ++i) {
  try {
      // Act
      Object result = myTypeStub.myMethod();

      // Assert
      Assert.fail('Expected exception was not thrown');
  } catch (Exception ex) {
      Assert.isInstanceOfType(ex, MyException.class);
  }
}
Global throws once

Configure it to throw a specific exception once, whatever the parameter received The stub will throw the configured exception once

// Arrange
myMethodSpy.throwsExceptionOnce(new MyException());
try {
    // Act
    Object result = myTypeStub.myMethod();

    // Assert
    Assert.fail('Expected exception was not thrown');
} catch (Exception ex) {
    Assert.isInstanceOfType(ex, MyException.class);
}

Have a look at the ThrowsOnce recipe

Parameterized configuration

Configure it to return a specific value, when call with specific parameters Configure it to return a specific value once, when call with specific parameters Configure it to return a specific value times N, when call with specific parameters Configure it to throw a specific value, when call with specific parameters Configure it to throw a specific value once, when call with specific parameters Configure it to throw a specific value times N, when call with specific parameters

// Arrange
myMethodSpy
    .whenCalledWith(Argument.any(), 10)
    .thenReturn(new Account(Name='Test'));

// Arrange
myMethodSpy
    .whenCalledWith(Argument.any(), 10)
    .thenReturnOnce(new Account(Name='Test Once'));

// Arrange
myMethodSpy
    .whenCalledWith(Argument.any(), 10)
    .thenReturn(new Account(Name='Test Times'), 3);

// Arrange
myMethodSpy
    .whenCalledWith(Argument.any(), -1)
    .thenThrow(new MyException);

// Arrange
myMethodSpy
    .whenCalledWith(Argument.any(), -1)
    .thenThrowOnce(new MyOtherException());

// Arrange
myMethodSpy
    .whenCalledWith(Argument.any(), -1)
    .thenThrow(new MyException(), 3);


// Act
Object result = myTypeStub.myMethod('nothing', 10);

// Assert
Assert.areEqual(new Account(Name='Test Once'), result);

// Act
Object result = myTypeStub.myMethod('nothing', 10);

// Assert
Assert.areEqual(new Account(Name='Test'), result);

for(Integer i = 0 ; i < 3 ; ++i) {
  // Act
  Object result = myTypeStub.myMethod('nothing', 10);

  // Assert
  Assert.areEqual(new Account(Name='Test Times'), result);
}

// Act
try {
    myTypeStub.myMethod('value', -1);

    // Assert
    Assert.fail('Expected exception was not thrown');
} catch (Exception ex) {
    Assert.isInstanceOfType(ex, MyOtherException.class);
}

// Act
try {
    myTypeStub.myMethod('value', -1);

    // Assert
    Assert.fail('Expected exception was not thrown');
} catch (Exception ex) {
    Assert.isInstanceOfType(ex, MyException.class);
}

for(Integer i = 0 ; i < 3 ; ++i) {
  // Act
  try {
      myTypeStub.myMethod('value', -1);

      // Assert
      Assert.fail('Expected exception was not thrown');
  } catch (Exception ex) {
      Assert.isInstanceOfType(ex, MyException.class);
  }
}

Have a look at the mocking recipes to have a deeper overview of what you can do with the mocking API.

Configuration order matters !

TL;DR

The order of the spy configuration drive how it will behave.

  1. If no configuration at all, then return null (default behavior).
  2. Then, it checks the "matching" whenCalledWith once and times configurations and apply them in setup order.
  3. Then, it checks the "global once" and "global times" (returnsOnce or throwsExceptionOnce or returns(object, integer) or throws(exception, integer)) configuration and apply them in setup order.
  4. Then, it checks the "matching" whenCalledWith configurations and apply them in setup order.
  5. Then, it checks the "global" (returns or throwsException) configurations and apply them in setup order.

If there is a configuration and it does not match then it throws a ConfigurationException. The error message will contains the arguments and the configuration. Use it to help you understand the root cause of the issue (configuration/regression/you name it).

The order of the global configuration matters. If global throw is setup after global returns then throwException will apply.

myMethodSpy.returns(new Account(Name='Test'));
myMethodSpy.throwsException(new MyException());
Object result = myTypeStub.myMethod(); // throws

If global returns is setup after global throw then returns will apply

myMethodSpy.throwsException(new MyException());
myMethodSpy.returns(new Account(Name='Test'));
Object result = myTypeStub.myMethod(); // return configured value

For global configuration, the last configured will apply. Same as if you would have configured the spy twice to return (or throw), the last global configuration would be the one kept.

Assert on a spy

Use the Expect class to assert on a spy It exposes the method that and returns a MethodSpyExpectable type. Use the convenient assertion methods the following way:

// hasNotBeenCalled
Expect.that(myMethodSpy).hasNotBeenCalled();

// hasBeenCalled
Expect.that(myMethodSpy).hasBeenCalled();

// hasBeenCalledTimes
Expect.that(myMethodSpy).hasBeenCalledTimes(2);

// hasBeenCalledWith
Expect.that(myMethodSpy).hasBeenCalledWith('stringValue', Argument.any(), true, ...); // up to 5 parameters
Expect.that(myMethodSpy).hasBeenCalledWith(Argument.ofList(new List<Object>{Argument.any(), Argument.any(), ... })); // for more than 5 parameters

// hasBeenLastCalledWith
Expect.that(myMethodSpy).hasBeenLastCalledWith('stringValue', Argument.any(), true, ...); // up to 5 parameters
Expect.that(myMethodSpy).hasBeenLastCalledWith(Argument.ofList(new List<Object>{Argument.any(), Argument.any(), ... })); // for more than 5 parameters

Have a look at the assertions recipes to have a deeper overview of what you can do with the assertion API

Arguments

Configuring a stub (spy.whenCalledWith(...)) and asserting (Expect.that(myMethodSpy).hasBeenCalledWith and Expect.that(myMethodSpy).hasBeenLastCalledWith) a stub uses Argument.Matchable interface.

You can either use raw values with notation like spy.whenCallWith('value1', false, ...)or hasBeenCalledWith(param1, param2, ...) up to 5 arguments.

It wrapes value with a Argument.equals when called with any kind of parameter.

When called with a Argument.Matchable type, it considers it as a parameter, use it directly without wrapping it with a Argument.equals.

If you need more arguments in your method calls, Argument offers the ofList API to create parameters for that, so that you can do spy.whenCallWith(Argument.ofList(new List<Object>{...})))or hasBeenCalledWith(Argument.ofList(new List<Object>{...}))))

List<Argument.Matchable> emptyParameters = Argument.empty();

List<Argument.Matchable> myMethodParameters = Argument.of(10, 'string'); // Up to five

List<Argument.Matchable> myMethodWithLongParameters = Argument.ofList(new List<Object>{10, 'string', true, 20, false, 'Sure'});

Argument matcher

The library provide OOTB (out of the box) Matchables ready for use and fully tested. The library accept your own matchers for specific use cases and reusability.

Any

Argument.any() matches anything

Argument.any();

Equal

Argument.equals() (the default) matches with native deep equals

Argument.equals(10);

jsonEqual

Argument.jsonEquals(new WithoutEqualsType()) matches with json string equals. Convenient to match without equals type

Argument.jsonEquals(new WithoutEqualsType(10, true, '...'));

Namespaced custom types must add the @JsonAccess annotation with `serializable='always' to the class when using the unlocked package version.

ofType

Argument.ofType() matches on the parameter type

// To match any Integer
Argument.ofType('Integer');
// To match any Account SObject
Argument.ofType(Account.getSObjectType());
// To match any CustomType class instance
Argument.ofType(CustomType.class);

BYOM (Build your own matcher)

Use the Argument.Matchable interface and then use it with Argument APIs

@isTest
public class MyMatchable implements Argument.Matchable {
  public Boolean matches(Object callArgument) {
    boolean matches = false;

    // custom logic to determine if it matches here
    ...

    return matches;
  }

  public Boolean equals(Object obj) {
      if (obj == null || !(obj instanceof MyMatchable)) {
        return false;
      }
      MyMatchable other = (MyMatchable) obj;
      return <this fields custom comparison with other instance>;
    }
}

List<Argument.Matchable> args = Argument.of(new MyMatchable(), ...otherArguments);

Implements the public Boolean equals(Object obj) method on your custom matchable so we can compare list of arguments

Have a look at the overview recipes to have a deeper overview of what you can do with the library

Recipes

They have their own folder. It contains usage example for mocking and asserting It contains one classe for each use cases the library covers

Mocking

Asserting

Library architecture

The library repository has 3 parts:

  • Test classes in the force-app/src folder are what you need to use the lib, no more. Installation button deploy this folder.
  • Test classes in the force-app/test folder are what we need to maintain the library and is not required in production.
  • Test classes in the force-app/recipes folder are what you can use to have a deeper understanding of the library usages.

apex mockery class diagram

How to migrate my codebase?

Considering the concept of test pyramid, the importance of unit test in term of maintainability, how it impacts positively the deployment speed and how the lib can help you doing that, how do I migrate my entire code base to have a well balanced test pyramid ?

Here are the way we suggest to follow to enforce proper unit test

When implementing new feature

Look for unit test changes at the PR level, ensure the unit test is well decoupled from its dependencies

  • Ensure the code base stays clean
  • Use the code review stage as an enablement tool for the team

When touching existing/legacy code

Decouple production code using dependency injection Then rewrite unit tests

  • Speed up test execution of this area of the code
  • Improve maintainability and Developer Experience

When refactoring

Decouple production code using dependency injection Then write unit tests Then you can either shrink or delete old integrated test

  • The apex class under test becomes decoupled from its dependencies
  • First step towards SOLID design, you’ll be able to segregate responsibility further

Authors

Contributing

Any contributions you make are appreciated.

See contributing.md for apex-mockery contribution principles.

License

This project license is BSD 3 - see the LICENSE.md file for details