event-driven-io / emmett

Emmett - a Node.js library taking your event-driven applications back to the future!
https://event-driven-io.github.io/emmett/
179 stars 16 forks source link

Test: Devise a testing strategy that we can use to reduce duplication among emmet packages #34

Closed thiagomini closed 5 months ago

thiagomini commented 6 months ago

Description

As we noted in this PR, some test cases are duplicated among our packages. Therefore, when we want to fix them or change something, we have to repeat the same code in multiple files, which is unnecessary and prone to human error. We need to create some sort of matrix testing or a testing utility that can be reused across packages to execute the same logical test.

A few things to discuss:

Acceptance Criteria

  1. Definition and agreement of a testing strategy that can reduce the boilerplate / duplicate code between tests
  2. Implementation of the aforementioned strategy in existing tests

Additional Information

To inspire this, we can use ESLint's Testing Helper. They've created a tool to facilitate creating tests that are usually very similar.

oskardudycz commented 6 months ago

I was thinking that we could locate such tests into either the main src folder or other place. If we had either base class for tests, or generator that would take the event store implementation and some specific settings (e.g. EventStoreDB adds first event at 0 position, while others on 1 ) then this could be quite easy way to deal with permutations.

Potentially we could have the curried function that would return the describe and run it for multiple store implementations.

Of course, we should also eventually document the requirements and test cases in some markdown (and eventually docs).

thiagomini commented 6 months ago

Hey @oskardudycz , before implementing anything, I'm trying to understand the base requirements for our "toy" domain (shopping carts). I realized that we have two layers in our tests:

  1. The domain layer comprises ShoppingCart, ProductItem entities and the rules around the ProductItemAdded and DiscountApplied events. These are just the "essential complexities" necessary to test saving and aggregating back an event stream.
  2. The infra layer comprises the underlying event store implementation and features. In the examples above, we are testing the "Time Traveling" feature.

Most of the duplication lies in the domain layer, which should be the same for any implementation. We might have some changes in the infra layer, and we need some abstraction over it. I've created these gherkin-like descriptions for these two layers:

Domain Layer

Feature: ShoppingCart
  Scenario: Add a product to the shopping cart
    Given I have a shopping cart
    When I add a product to the shopping cart
    Then the shopping cart should contain 1 product

  Scenario: Add a product to the shopping cart changes the total price
    Given I have an empty shopping cart
    And there is a product item with id "Book-1" and price 3
    When I add a 10 units to the shopping cart
    Then the total price of the shopping cart should be 30

  Scenario: Apply a discount to the shopping cart
    Given there is a product item with id "Book-1" and price 3
    And I have a shopping cart with 10 units of product "Book-1"
    When I apply a 10% discount to the shopping cart
    Then the total price of the shopping cart should be 27

Infra Layer

Feature: Time-Travel

  Scenario: Travels to the first version of an event stream
    Given an event store with the following events:
      | EventID | EventType          | EventData                                             |
      | 1       | ProductItemAdded   | { "productId": "Book-1", "quantity": 10, "price": 3 } |
      | 2       | ProductItemAdded   | { "productId": "Book-1", "quantity": 10, "price": 3 } |
      | 3       | DiscountApplied    | { "percent": 10 }                                     |
    When I travel to the **first** version of the event stream
    Then the ShoppingCart should have the following state:
      {
        "totalAmount": 30,
        "productItems": [
          { "productId": "Book-1", "quantity": 10, "price": 3 }
        ],
      }

  Scenario: Travels to the second version of an event stream
    Given an event store with the following events:
      | EventID | EventType          | EventData                                             |
      | 1       | ProductItemAdded   | { "productId": "Book-1", "quantity": 10, "price": 3 } |
      | 2       | ProductItemAdded   | { "productId": "Book-1", "quantity": 10, "price": 3 } |
      | 3       | DiscountApplied    | { "percent": 10 }                                     |
    When I travel to the **second** version of the event stream
    Then the ShoppingCart should have the following state:
      {
        "totalAmount": 60,
        "productItems": [
          { "productId": "Book-1", "quantity": 10, "price": 3 },
          { "productId": "Book-1", "quantity": 10, "price": 3 },
        ],
      }

  Scenario: Travels to the third version of an event stream
    Given an event store with the following events:
      | EventID | EventType          | EventData                                             |
      | 1       | ProductItemAdded   | { "productId": "Book-1", "quantity": 10, "price": 3 } |
      | 2       | ProductItemAdded   | { "productId": "Book-1", "quantity": 10, "price": 3 } |
      | 3       | DiscountApplied    | { "percent": 10 }                                     |
    When I travel to the **third** version of the event stream
    Then the ShoppingCart should have the following state:
      {
        "totalAmount": 54,
        "productItems": [
          { "productId": "Book-1", "quantity": 10, "price": 3 },
          { "productId": "Book-1", "quantity": 10, "price": 3 },
        ],
      }

I'd like your review of both cases, Oskar, and to confirm if it makes sense. If we agree, the following steps would be:

  1. Extracting the domain logic to some folder. That would comprise the events, the types and the evolve functions
  2. Creating a DSL to abstract away the scenarios for the Time Travel Feature as described by the gherkin example. We could do something like:
// Given
const shoppingCart = await dsl.shoppingCart.addProductItems(...productItems);
await dsl.shoppingCart.applyDiscount(shoppingCart, 10);

// When
await eventStore.aggregateStream(shoppingCartId, {
        evolve,
        getInitialState,
        read: { to: 1n },
});
// Then
dsl.shoppingCart.shouldEqual(expectedState)

the dsl object would be an abstraction where we could implement how it works with different drivers for different ES implementations. I took inspiration from Dave Farley's video on DSLs: https://youtu.be/JDD5EEJgpHU?si=gP5CHBFlWMPVKAhz&t=379