serverlesstechnology / cqrs

A lightweight, opinionated CQRS and event sourcing framework.
Other
370 stars 40 forks source link

Support the TestFramework inside Cucumber tests #53

Closed liamwh closed 7 months ago

liamwh commented 1 year ago

Hi serverlesstechnology,

Once again, I'm loving the framework and intend to promote it heavily very soon!

One major thing I need to achieve before doing so, is leveraging the TestFramework inside cucumber tests. Being able to use .feature files to setup BDD test scenarios is important to effectively communicate the tests with stakeholders who cannot read code.

For example, I'd love to be able to do something like this:

#[derive(World)]
pub struct BankAccountWorld {
    test_framework: AccountTestFramework,
}

impl Default for BankAccountWorld {
    fn default() -> Self {
        let services = BankAccountServices::new(Box::<MockBankAccountServices>::default());
        BankAccountWorld {
            test_framework: AccountTestFramework::with(services),
        }
    }
}

#[given(regex = r"^I want to be able to open a bank account with ID (\d+)$")]
pub fn given_i_want_to_be_able_to_open_a_bank_account_with_id(
    world: &mut BankAccountWorld,
    account_id: String,
) {
    world.test_framework = world
        .test_framework
        .given(vec![BankAccountEvent::AccountOpened {
            account_id: (account_id),
        }]);
}

However, the methods on the test framework build a AggregateTestExecuter, and consume self, instead of borrowing, so any means of storing state in the World struct becomes a bit of a hacky work around.

    ...
    #[must_use]
    pub fn given(self, events: Vec<A::Event>) -> AggregateTestExecutor<A> {
        AggregateTestExecutor {
            events,
            service: self.service,
        }
    }

Could we perhaps implement a cleaner solution so that the given, when and then methods do not have to be chained and state can be stored between calls? If you have a preference for direction, I'd be happy to contribute if you prefer not to write the code yourself 👍

davegarred commented 1 year ago

I assume the issue that you're having is around the Gherkin And steps which conflict with the rigidity of the test framework that enforces:

list of events + command ==> list of events

I think this enforcement is ideal in most cases (i.e., unit tests) as it encourages the developer to think in CQRS terms as well as helps debugging state issues. The question then is the best way to keep this rigidity for most cases while providing some flexibility when Cucumber is desired.

It might be overkill but should the solution also be extensible for other test methodologies (e.g., contract testing)?

liamwh commented 1 year ago

After thinking about this issue with a fresh mind, I realised I should be able to simply create a vec of Commands / Events / Errors in the World struct cucumber provides, and then just set up and execute the test framework in the "then" part of the BDD test. I validated this theory and the below code is working. Please excuse the scrappy code, I just slapped it together quickly as a POC:

#[derive(Debug, Default, World, Clone)]
pub struct BankAccountWorld {
    given_events: Vec<BankAccountEvent>,
    when_comand: Option<BankAccountCommand>,
    then_events: Vec<BankAccountEvent>,
    then_error: Option<BankAccountError>,
}

fn get_test_framework() -> AccountTestFramework {
    let services = BankAccountServices::new(Box::<MockBankAccountServices>::default());
    AccountTestFramework::with(services)
}

#[given(regex = r"^I want to open a bank account with ID (\d+)$")]
async fn given_i_want_to_open_a_bank_account_with_id(
    world: &mut BankAccountWorld,
    account_id: String,
) {
    world.when_comand = Some(BankAccountCommand::OpenAccount(
        BankAccountOpenAccountCommandData {
            account_id: account_id.clone(),
        },
    ));
    dbg!(&world.when_comand);
}

#[then(regex = r"^I should have a bank account with ID (\d+)$")]
async fn i_should_have_a_bank_account_with_id(world: &mut BankAccountWorld, account_id: String) {
    world.then_events.push(BankAccountEvent::AccountOpened {
        account_id: account_id.clone(),
    });

    let world = world.clone();

    tokio::task::spawn_blocking(move || {
        get_test_framework()
            .given(world.given_events.clone())
            .when(
                world
                    .when_comand
                    .clone()
                    .expect("Expected a command to bet set"),
            )
            .then_expect_events(world.then_events.clone());
    });
}

I really like your thinking with the rigidity/enforcement using the framework being ideal Dave. So as I no longer have the need for any additional action from the framework's part, I'm happy to consider this closed, but I'll leave the issue open and up to you if you decide to do anything with it 👍

liamwh commented 1 year ago

I've been playing around with it a fair amount, and I've gone back on my previous statement. I'm being forced to apply various workarounds, which would simply be much simpler if I wasn't forced to immediately chain .when and .then methods after the .given method.

serverlesstechnology commented 1 year ago

@liamwh any ideas on additions that might help?

Adding an and implementation to the given could help a bit I think, thoughts?

liamwh commented 1 year ago

I certainly agree that adding an and implementation would be a more ergonomic API and therefore fully support that addition. 👍

However, I don't feel that solves the issues using the testing framework inside the cucumber tests, because of spawning another runtime:

image

Ideally, I'd love to be able to use the framework inside the cucumber tests as economically as possible, something like the following:

image

davegarred commented 1 year ago

I've added an and to get started and investigating that runtime.

Can you push your experiment for me to take a look at (if this is in Veloxide maybe a branch)?

liamwh commented 1 year ago

Hi, apologies for the delay, I must've overlooked any notification and am only back here because I checked it manually. Regardless, here's a link to the branch that I was playing around on as requested. Thanks for looking into this! https://github.com/liamwh/Veloxide/tree/include-bank-account-bdd-tests

davegarred commented 1 year ago

The solutions that I've tried feel both brittle and a little incorrect. Instead I've opted to add a method when_async that should allow this to be used under an async test. I'll continue to investigate this and combine them in the next major release if I can find a clean way to do.

An async test should now look something like:

#[tokio::test]
async fn test() {
    let executor = TestFramework::<MyAggregate>::with(MyService)
        .given_no_previous_events();

    let validator = executor.when_async(MyCommands::DoSomething).await;
   ...
}
davegarred commented 1 year ago

@liamwh is this solution working for you?