Trompeloeil Mocking Framework

repository·main·Indexed 21 days ago

https://github.com/rollbear/trompeloeil

A thread-safe, header-only mocking framework for C++11/14. It enables developers to define expectations, return values, and side effects for virtual functions in unit tests using macros like MAKE_MOCK, REQUIRE_CALL, and FORBID_CALL. The library supports a C++14-first API with a backported variadic API for C++11 compliance and provides adapters for integration with testing frameworks such as Catch2, gtest, doctest, and CxxTest.

Tokens
31.3K
Snippets
95
Records
126
Agent score
71%

What's inside Trompeloeil

  1. What is Trompeloeil?

    main
    Trompeloeil is a thread-safe, header-only mocking framework for C++11/14. It is distributed under the Boost Software License 1.0 and is designed to facilitate unit testing by allowing developers to create mock objects that simulate complex interfaces and behaviors.
  2. Modify local variables using `LR_*` variants

    main

    Standard clauses like .WITH(), .SIDE_EFFECT(), .RETURN(), and .THROW() capture local variables by value. This means changes made to a variable inside these clauses will not affect the variable in the test scope.

    To modify a local variable in your test, you must use the 'Local Reference' variants:

    • LR_SIDE_EFFECT()
    • LR_WITH()
    • LR_RETURN()
    • LR_THROW()
  3. Why mock objects are not move constructible

    main

    By default, Trompeloeil mock objects are not move constructible. This is a safety measure: if a mock object is moved, the actions associated with its expectations (such as .WITH(), .SIDE_EFFECT(), .RETURN(), or .THROW()) are not moved. If these actions refer to data members within the original mock object, they would end up pointing to invalid memory in the moved object.

    If you explicitly need movable mocks, you can use the trompeloeil_movable_mock class.

  4. Understand the interaction between IN_SEQUENCE and TIMES

    main

    Mixing .TIMES() and .IN_SEQUENCE() can lead to unexpected behavior because sequences are observed from a sequence object that moves forward only when a step is satisfied and saturated.

    If you use .TIMES(AT_LEAST(1)) within a sequence, the sequence object will allow the call, but it might also allow the same call to repeat. However, once the call is satisfied, the sequence object attempts to move to the next step in the sequence. If the next step is a different function, and you call the first function again, it will trigger a sequence violation because the sequence object was expecting the next step, not a repeat of the current one.

    trompeloeil::sequence seq;
    REQUIRE_CALL(mock, foo1)
      .TIMES(AT_LEAST(1))
      .IN_SEQUENCE(seq);
    REQUIRE_CALL(mock, foo2)
      .IN_SEQUENCE(seq);
    REQUIRE_CALL(mock, foo3)
      .IN_SEQUENCE(seq);
    
    // This sequence of calls will fail:
    mock.foo1(); // OK: satisfies foo1
    mock.foo2(); // OK: satisfies foo2
    mock.foo1(); // ERROR: sequence expected foo3, but got foo1
  5. Understand 'saturated' expectations

    main

    A "saturated" expectation occurs when an expectation for a call exists, but the maximum number of allowed calls (defined by .TIMES()) has already been met. Subsequent calls to that function will trigger a violation report stating that the call "Matches saturated call requirement".

    Example of saturation:

    test_func()
    {
        test_mock obj;
        REQUIRE_CALL(obj, func(trompeloeil::_))
          .TIMES(AT_MOST(2));
    
       exercise(obj); // OK: 1st call
       exercise(obj); // OK: 2nd call
       exercise(obj); // FAIL: Expectation is saturated (max 2 reached)
    }
    test_func()
    {
        test_mock obj;
        REQUIRE_CALL(obj, func(trompeloeil::_))
          .TIMES(AT_MOST(2));
    
       exercise(obj); // OK. Expectation is alive, no prior calls, this one is accepted
       exercise(obj); // OK. Expectation is alive, one prior call, this one is accepted
       exercise(obj); // Fail. Expectation is alive, two prior calls, this one saturated
    }
  6. Enable move construction for mock objects

    main

    By default, mock objects are immobile. To make a mock object move-constructible, add a static constexpr boolean member to your mock class:

    static constexpr bool trompeloeil_movable_mock = true;

    Important Considerations:

    • Expectation Transfer: When a mock is moved, current expectations are transferred to the new object.
    • Lambda Capture Danger: If expectations use .WITH(), .SIDE_EFFECT(), .RETURN(), or .THROW() and capture member variables of the original mock object, the lambdas will continue to refer to the old (moved-from) object. This can lead to undefined behavior or logic errors.
    • Lifetime Issues: If an expectation's lifetime is tied to the moved-from object (e.g., an expectation created inside a function that returns the mock), the expectation might be destroyed before it is satisfied.

    Using NAMED_REQUIRE_CALL, NAMED_ALLOW_CALL, or NAMED_FORBID_CALL can help make expectation lifetimes more explicit.

    Requires #include <trompeloeil/mock.hpp>.

    class movable
    {
    public:
      int i = 0;
    
      static constexpr bool trompeloeil_movable_mock = true;
      // allow move construction
    
      MAKE_MOCK1(func, void(int));
    };
    
    test(...)
    {
      movable m{3};
      auto e = NAMED_REQUIRE_CALL(m, func(_))
        .LR_WITH(_1 == m.i);
      auto mm = transfer(std::move(m)); // Danger! e still refers to m.i.
      ...
    }
  7. Control call sequences with .IN_SEQUENCE()

    main

    By default, expectations are logically parallel. If you need to enforce a specific order of calls, use trompeloeil::sequence.

    1. Create a trompeloeil::sequence object.
    2. Attach each expectation to that sequence using .IN_SEQUENCE(seq).

    This is useful for:

    • Imposing order on logically parallel calls (e.g., open -> write -> close).
    • Distinguishing between multiple calls that match the same parameters (e.g., a first call that fails and a second call that succeeds).

    .IN_SEQUENCE(...) can also be used with REQUIRE_DESTRUCTION and NAMED_REQUIRE_DESTRUCTION.

    class FileOps
    {
    public:
      using handle = int;
      MAKE_MOCK(open, auto (const std::string&) -> handle);
      MAKE_MOCK(write, auto (handle, const char*, size_t) -> size_t);
      MAKE_MOCK(close, auto (handle) -> void);
    };
    
    using trompeloeil::ne;
    
    void test()
    {
      FileOps ops;
      trompeloeil::sequence seq;
      int handle = 4711;
    
      REQUIRE_CALL(ops, open("name"))
        .RETURN(handle)
        .IN_SEQUENCE(seq);
    
      REQUIRE_CALL(ops, write(handle, ne(nullptr), ne(0)))
        .RETURN(_3)
        .IN_SEQUENCE(seq);
    
      REQUIRE_CALL(ops, close(handle))
        .IN_SEQUENCE(seq);
    
      test_writes(&ops);
    }
  8. Core concepts of Trompeloeil

    main

    Trompeloeil is a C++ mocking framework built around several key abstractions:

    • Mock function: A simulated implementation of a function that allows you to define expectations on how it is called.
    • Mock object: An object that contains one or more mock functions, used to simulate a complex interface or class.
    • Expectation: A rule defined for a mock function that specifies which arguments are acceptable, how many times the function should be called, and what it should return or do.
    • Matcher: A tool used within an expectation to validate the arguments passed to a mock function. Matchers can check for equality, ranges, specific types, or even use regular expressions.
  9. Set expectations with ALLOW_CALL, REQUIRE_CALL, and FORBID_CALL

    main

    Trompeloeil uses expectations to define the behavior of mock functions. By default, all calls to mock functions are illegal and will be reported as violations. You can use three basic types of expectations:

    • ALLOW_CALL(...): Used for defaults. It can match any number of times.
    • REQUIRE_CALL(...): Stricter; it defaults to matching exactly once, but you can control the match count.
    • FORBID_CALL(...): Explicitly forbids a call. Useful when combined with ALLOW_CALL or REQUIRE_CALL to prevent specific calls that would otherwise be accepted.

    Scoping and Lifetime: Expectations are active until the end of their scope. If multiple expectations match a call, the last matching expectation created is used. This allows you to set a wide default in a outer scope and use narrow, temporary expectations in a local scope.

    If you need to control the lifetime manually, use the named versions which return a std::unique_ptr<trompeloeil::expectation>:

    • NAMED_ALLOW_CALL(...)
    • NAMED_REQUIRE_CALL(...)
    • NAMED_FORBID_CALL(...)
    class Mock
    {
    public:
      MAKE_MOCK(func, auto (int) -> void);
      MAKE_MOCK2(func, auto (const char*) -> void);
    };
    
    void test()
    {
      Mock m;
      ALLOW_CALL(m, func(1));         // int version any number of times
      REQUIRE_CALL(m, func(nullptr)); // const char * version exactly once
      func(&m);
      // expectations must be met before end of scope
    }
  10. Thread safety and using the global lock

    main

    Trompeloeil is thread-safe using a global recursive_mutex that protects expectations. This allows expectations and mock functions to be accessed across different threads.

    Warning: You must ensure the mock object is not deleted while establishing an expectation or calling a mock function.

    If you need to manually access the lock in your tests, use trompeloeil::get_lock().

    auto lock = trompeloeil::get_lock();
    // lock holds the recursive_mutex until it goes out of scope
  11. How sequence objects work with expectations

    main

    You can use trompeloeil::sequence objects to enforce a specific order of calls across different expectations. When an expectation uses .IN_SEQUENCE(seq), it must occur in the order defined by the sequence objects.

    If multiple expectations share the same sequence object, they must occur in the order they were defined. If an expectation is listed in multiple sequences, it must satisfy the constraints of all of them.

    Greedy Behavior: .IN_SEQUENCE() combined with .TIMES(min, max) is greedy. The expectation will remain active and match calls as long as it matches and hasn't reached its upper bound, or until a different expectation matches. This allows a single expectation to 'consume' calls before the sequence moves to the next step.

    class Mock
    {
    public:
      MAKE_MOCK1(func, void(int));
    };
    
    TEST(a_test)
    {
      Mock m;
      trompeloeil::sequence seq;
    
    REQUIRE_CALL(m, func(0))
        .IN_SEQUENCE(seq)
        .TIMES(1, 5);
    
    ALLOW_CALL(m, func(0))
        .IN_SEQUENCE(seq)
        .SIDE_EFFECT(std::cout << "extra\n");
    
    REQUIRE_CALL(m, func(1))
        .IN_SEQUENCE(seq);
    
    test_func(m);
    }
  12. Create duck-typed matchers with `wildcard`

    main

    A duck-typed matcher accepts any type that supports a specific set of operations (e.g., a .empty() method). To implement this, use trompeloeil::wildcard as the type argument for trompeloeil::make_matcher<trompeloeil::wildcard>(...).

    Crucial Requirement: The predicate lambda must use a trailing return type specifier that utilizes the required operations. This allows SFINAE (Substitution Failure Is Not An Error) to filter out incompatible types at compile time, providing a clear error at the call site (REQUIRE_CALL, etc.) rather than deep inside the matcher logic.

    Example of a not_empty() matcher:

      inline auto not_empty()
      {
        return trompeloeil::make_matcher<trompeloeil::wildcard>(
          // predicate lambda with trailing return type for SFINAE
          [](auto const& value) -> decltype(!value.empty()) {
            return !value.empty();
          },
          // print lambda
          [](std::ostream& os) {
            os << " is not empty";
          }
        );
      }
      inline auto not_empty()
      {
        return trompeloeil::make_matcher<trompeloeil::wildcard>(
          [](auto const& value) -> decltype(!value.empty()) {
            return !value.empty();
          },
          [](std::ostream& os) {
            os << " is not empty";
          }
        );
      }