Skip to content

Instantly share code, notes, and snippets.

@bwaterschoot
Last active December 22, 2015 10:26
Show Gist options
  • Save bwaterschoot/a8548dd21a9c87a0f712 to your computer and use it in GitHub Desktop.
Save bwaterschoot/a8548dd21a9c87a0f712 to your computer and use it in GitHub Desktop.
Event projector tests to in memory readmodel based on AggregateSource testing
public class DonationProjectorTests
{
private Func<IEventStorage, InMemoryDonationProjector> _sutFactory;
[Test]
public void Donation_readmodel_should_be_initialised_correctly()
{
new ProjectorScenarioFor<InMemoryDonationProjector, DonationDataModel>(_sutFactory, DonationStreamName.FromString, "d15f54ee8204445a95cff19e826a9746")
.When(new DonationCollectionStarted("d15f54ee8204445a95cff19e826a9746", "09a78a86d78445c58225ab88f0e31aea-v1",
"BLA-BLA", DateTimeOffset.Parse("2015-12-15 11:14:01.088")))
.Then((model) =>
{
model.DonationCollectionId = "d15f54ee8204445a95cff19e826a9746";
model.ReferralCode = "BLA-BLA";
model.DonationStartedTime = DateTimeOffset.Parse("2015-12-15 11:14:01.088");
model.AdditionalData = Maybe<string>.Empty;
return model;
})
.Assert();
}
[Test]
public void Donation_to_suspended_cause_should_invalidate_the_readmodel()
{
new ProjectorScenarioFor<InMemoryDonationProjector, DonationDataModel>(_sutFactory, DonationStreamName.FromString, "d15f54ee8204445a95cff19e826a9746")
.Given(
new DonationCollectionStarted(
"d15f54ee8204445a95cff19e826a9746",
"09a78a86d78445c58225ab88f0e31aea-v1",
"BLA-BLA",
DateTimeOffset.Parse("2015-12-15 11:14:01.088")))
.When(new DonationToSuspendedCauseAttempted("d15f54ee8204445a95cff19e826a9746",
"09a78a86d78445c58225ab88f0e31aea-v1"))
.Then((model) =>
{
model.Invalid = true;
model.Reason = "Can't donate to a suspended cause";
return model;
})
.Assert();
}
[SetUp]
public void Setup()
{
var additionalDataProvider = new Mock<IAdditionalDataProvider>();
additionalDataProvider.Setup(x => x.GetAsync("d15f54ee8204445a95cff19e826a9746")).Returns(Task.FromResult(Maybe<string>.Empty));
_sutFactory = (eventStore) => new InMemoryDonationProjector(eventStore, additionalDataProvider.Object);
}
}
public class ProjectorScenarioFor<TProjector, TModel>
where TProjector : IProjectTo<TModel>
where TModel : new()
{
private readonly Func<IEventStorage, TProjector> _projectorFactory;
private readonly string _id;
private TModel _beforeProjection;
private TModel _afterProjection;
private TModel _expectedProjection;
private object[] _givenEvents = new object[0];
private readonly string _storageId;
public ProjectorScenarioFor(Func<IEventStorage, TProjector> projectorFactory, Func<string, string> storageIdMapper, string id)
{
_projectorFactory = projectorFactory;
_id = id;
_storageId = storageIdMapper(id);
_beforeProjection = new TModel();
}
public ProjectorScenarioFor<TProjector, TModel> Given(params object[] @events)
{
_givenEvents = @events ?? new object[0];
var eventStore = new Mock<IEventStorage>();
eventStore.Setup(e => e.GetAsync(_storageId)).Returns(Task.FromResult(@events.AsEnumerable()));
var projector = _projectorFactory(eventStore.Object);
_beforeProjection = projector.GetByIdAsync(_id).Result;
return this;
}
public ProjectorScenarioFor<TProjector, TModel> When(params object[] @events)
{
var eventStore = new Mock<IEventStorage>();
var eventsToRun = _givenEvents.Concat(@events);
eventStore.Setup(e => e.GetAsync(_storageId)).Returns(Task.FromResult(eventsToRun));
var projector = _projectorFactory(eventStore.Object);
_afterProjection = projector.GetByIdAsync(_id).Result;
return this;
}
public ProjectorScenarioFor<TProjector, TModel> Then(Func<TModel, TModel> expectation)
{
_expectedProjection = expectation(_beforeProjection);
return this;
}
public void Assert()
{
_afterProjection.ShouldBeEquivalentTo(_expectedProjection);
}
}
@yreynhout
Copy link

Couple of things that come to mind:

  • I would try to get rid of mocking as much as possible. Try to extend your test code with stubs instead. Similarly to how you initialize e.g. an event store, you could provide this data to the stub before actually running the projection. You will need some test-syntax sugar to set that up properly. Makes for more readable and less brittle tests IMO (imagine going from an interface to a delegate or renaming methods on the interface or changing arguments and order there of on the interface or re-partitioning the behavior on the interface).
  • Personally, I've stopped focusing on the "W" in GWT tests for projections. I'm now using simple GT tests, since it's more natural for projections (motivation: there's no real when, it all happened - focus on the when would be the only reason to cling to it).
  • Try to tuck away the boilerplate initialization in each test: ProjectorScenario.For(new InMemoryDonationProjector());
  • Stick to "object" comparison as much as possible. I'm not sure why you're "changing" the model in the Then step. I assume there's some change tracking behind the scenes. If not, and the idea is to fill up the model as to how it should look like, why not new DonationDataModel { ... }? If you're worried about having to compare too many properties, you could use specialized comparers, but honestly, here be dragons. The danger is that you only compare what you're interested in and that the code under test might change in the future and have undesired side effects.
  • Test names are best written in terms of succint preconditions, not the side effects they have: when_a_donation_collection_started, when_a_donator_donated_to_a_suspended_cause

@bwaterschoot
Copy link
Author

I have added the actual code for the ProjectorScenario to add some context.

Some good suggestions there, will take a look at them.

We are doing object comparison, however I only specify what should change to the model when that event occurs.

@yreynhout
Copy link

I think you're limiting yourself when you're limiting your testing fx to 1 stream at a time IF that stream is a partition on the write side (e.g. an aggregate). Conceptually, each projection has its own stream - but it might be a stream projected off of other (typically aggregate) streams. I don't know whether this was a deliberate choice, if not, beware.

@bwaterschoot
Copy link
Author

Some comments:

  • I do like the GWT style, I just read it as:
    • Given state x
    • When projecting/replaying event y
    • Then only the following state changes should have happened
  • Which in a sense could be a written as:
    • Given state (x+y)
    • Then state is expected state

The difference between the 2 approaches is that I need to setup the entire model for the GT approach and only the specific state changes in the GWT?

  • It currently is limited to 1 stream as we only have 1 aggregate in this "micro" service, hence the way the projector was build.

@yreynhout
Copy link

  • I tend to read it more like this (a matter of preference, I acknowledge)
    • Given these things have happened
    • Expect the store (memory is a store as well) we projected into to look like this
  • You're coupling yourself to implementation details of the projection in the Given state step, IMO. It's worse enough that the Expect/Then step is doing that. I don't worry too much about too much setup since it's a strong test smell indicator.
  • If you're aware that you're feeding off of 1 aggregate stream - by all means, have a ball :-)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment