Domain-Driven Design With Laravel - 5
Domain-Driven Design With Laravel - 5
1 / 36
Martin Joo - Testing Complex Features - Dark
Introduction
In this short case study, I want to show you how you can test complex features. You know,
anything outside the standard CRUD functionality. Three features serve as excellent
examples:
If you're not familiar with these features, please read them in the book (at least the design
chapter) to better understand what's going on. I'm going to test these features with
integration or feature tests, so we're not going to mock anything and will actually run
everything needed to get the job done. In my opinion, that's the most effective way to test
these complex user stories. Without further ado, let's start with the most simple one:
importing subscribers from CSV.
2 / 36
Martin Joo - Testing Complex Features - Dark
class ImportSubscribersAction
{
public static function execute(string $path, User $user): void
{
ReadCsvAction!"execute($path)
!#each(function (array $row) use ($user) {
$parsed = [
!!$$row,
'tags' !% self!"parseTags($row, $user),
];
$data = SubscriberData!"from($parsed);
if (self!"isSubscriberExist($data, $user)) {
return;
}
UpsertSubscriberAction!"execute($data, $user);
});
}
}
3 / 36
Martin Joo - Testing Complex Features - Dark
As the first step, we need to identify the different test cases at a high level:
Given a CSV with four rows that contain four new subscribers, it will insert four
subscribers.
It will not duplicate existing subscribers.
Given subscribers with different tags, it will associate the tags with the subscribers.
It will not duplicate existing tags.
These are the basic test cases. It doesn't seem too hard. The next challenge is: dealing
with the CSV. If you look at the ImportSubscribersAction , it accepts a string $path
argument and will read the given path. This is good news because:
We will create a test CSV file with dummy data in the storage directory.
The test will read this file and will import these subscribers.
As I said earlier, I won't mock anything. If this class reads a CSV with a given format, it's
crucial that the test also reads a CSV with the same format as the "production CSV." This
way, I'm 100% sure that the action works as expected.
I put this dummy CSV into the storage/testing/subscribers.csv path, and this is the
content of the file:
4 / 36
Martin Joo - Testing Complex Features - Dark
Make sure that the action can actually read and import the CSV
namespace Tests\Feature\Subscriber;
ImportSubscribersAction!"execute(
storage_path('testing/subscribers.csv'),
$user
);
$this!#assertDatabaseCount('subscribers', 4);
}
}
The point of this test is to make sure everything works; we can read the file, the import
runs, and there are subscribers in the database after the import. Now, let's get a bit more
specific.
5 / 36
Martin Joo - Testing Complex Features - Dark
ImportSubscribersAction!"execute(
storage_path('testing/subscribers.csv'),
$user
);
$subscriber = Subscriber!"whereEmail('[email protected]')!#first();
$this!#assertTags($subscriber, ['Laravel', 'Vue', 'Inertia']);
$subscriber = Subscriber!"whereEmail('[email protected]')!#first();
$this!#assertTags($subscriber, []);
$subscriber = Subscriber!"whereEmail('[email protected]')!#first();
$this!#assertTags($subscriber, ['Laravel', 'DDD']);
$subscriber = Subscriber!"whereEmail('[email protected]')!#first();
$this!#assertTags($subscriber, ['DDD']);
}
In this test, I ensure that every subscriber has the appropriate tags from the CSV. Since I
needed to check it four times, I created a helper method called assertTags :
6 / 36
Martin Joo - Testing Complex Features - Dark
$subscriber = Subscriber!"whereEmail('[email protected]')!#first();
$this!#assertTags($subscriber, []);
It makes sure that the importer can handle subscribers with no tags. Empty cells are a
common source of errors in CSV or XLS imports. This test only handles non-existing new
tags.
7 / 36
Martin Joo - Testing Complex Features - Dark
Subscriber!"factory([
'email' !% '[email protected]',
])!#for($user)!#create();
ImportSubscribersAction!"execute(storage_path('testing/subscribers.csv'),
$user);
$this!#assertDatabaseCount('subscribers', 4);
}
The difference in this test is that it creates a subscriber with the e-mail
[email protected] before running the import. This e-mail address is the first one in the
CSV. After importing, it makes sure that there are only four subscribers in the database, so:
8 / 36
Martin Joo - Testing Complex Features - Dark
Tag!"factory(['title' !% 'Laravel'])!#for($user)!#create();
Tag!"factory(['title' !% 'DDD'])!#for($user)!#create();
ImportSubscribersAction!"execute(storage_path('testing/subscribers.csv'),
$user);
$this!#assertDatabaseCount('tags', 4);
}
The test creates two tags, Laravel and DDD. Both of them are in the CSV.
It runs the import
After it checks that there are only four tags in the database.
Now, if you don't like the magic number 4, you can create a constant:
9 / 36
Martin Joo - Testing Complex Features - Dark
This was you don't have magic numbers but readable constants.
This is how easy to test a file-related feature such as import. If you look at the action, you
can mentally see which test case tests which part of the action.
10 / 36
Martin Joo - Testing Complex Features - Dark
Running Automations
Now let's move on to a slightly more complicated feature. As a quick reminder, this is how
we run automations:
class RunAutomationsAction
{
public static function execute(
Subscriber $subscriber,
User $user,
Events $event
): void {
$automations = Automation!"with('steps')
!#whereBelongsTo($user)
!#whereHas('steps', function ($steps) use ($subscriber, $event) {
$steps
!#whereType(AutomationStepType!"Event)
!#whereName($event)
!#where('value!#id', $subscriber!#form_id);
})
!#get();
11 / 36
Martin Joo - Testing Complex Features - Dark
}
}
}
}
As you can see, there are jobs and events involved in this test, but fortunately, it's not a
problem:
<php>
<server name="APP_ENV" value="testing"!'
<server name="CACHE_DRIVER" value="array"!'
<server name="MAIL_MAILER" value="array"!'
<server name="QUEUE_CONNECTION" value="sync"!'
<server name="SESSION_DRIVER" value="array"!'
<server name="TELESCOPE_ENABLED" value="false"!'
!(php>
As you can see, the queue driver is set to sync, meaning everything will work
synchronously.
12 / 36
Martin Joo - Testing Complex Features - Dark
First, let's test the automation that adds tags to the subscriber:
!) To be continued
}
}
A form. This will be used in the automation itself. It's going to be the "trigger" of the
automation, something like "when someone subscribers to this particular form add a
tag to them."
Two tags. These two tags are going to be attached to the new subscriber.
The next step is to create the automation itself. We can use a factory to get it done, but it's
a bit more complicated and involves more than one factory, and also we need a for each
and so on. So instead of using a factory, what about using the UpsertAutomationAction ?
The whole purpose of this action is to create automation and all of its steps from a DTO.
This is an important rule: whenever you need to create some complex data structure in a
test or need some mechanics implemented by your "production classes," use them from
the tests!
13 / 36
Martin Joo - Testing Complex Features - Dark
UpsertAutomationAction!"execute(
AutomationData!"from([
'name' !% 'Test Automation',
'event' !% new AutomationStepData(
null,
Events!"SubscribedToForm!#value, $form!#id
),
'actions' !% [
new AutomationStepData(null, Actions!"AddTag!#value, $laravel!#id),
new AutomationStepData(null, Actions!"AddTag!#value, $ddd!#id),
]
]),
$user
);
}
Here I'm using two DTOs and the action to create an automation with two steps. It works
like this:
And now we can run this automation. There are two ways to do that.
14 / 36
Martin Joo - Testing Complex Features - Dark
if ($data!#form !* !$data!#id) {
event(new SubscribedToFormEvent($subscriber, $user));
}
The second option, of course, is to call the UpsertSubscriberAction from the test. I like
that one!
UpsertAutomationAction!"execute(
AutomationData!"from([
'name' !% 'Test Automation',
'event' !% new AutomationStepData(
null,
Events!"SubscribedToForm!#value, $form!#id
),
'actions' !% [
new AutomationStepData(null, Actions!"AddTag!#value, $laravel!#id),
new AutomationStepData(null, Actions!"AddTag!#value, $ddd!#id),
]
]),
$user
);
$subscriber = UpsertSubscriberAction!"execute(
SubscriberData!"from([
15 / 36
Martin Joo - Testing Complex Features - Dark
'email' !% '[email protected]',
'first_name' !% 'John',
'last_name' !% 'Doe',
'subscribed_at' !% now(),
'form' !% $form,
'tags' !% [],
]),
$user
);
$this!#assertTrue($subscriber!#tags()
!#where('tags.id', $laravel!#id)!#exists()
);
$this!#assertTrue($subscriber!#tags()
!#where('tags.id', $ddd!#id)!#exists()
);
}
As the last step, it creates the subscriber and asserts that it has the two tags. Once again:
16 / 36
Martin Joo - Testing Complex Features - Dark
This test is almost the same as the previous one, but now instead of tags, the automation
will add the subscriber to a given sequence:
$sequence = Sequence!"factory()!#create();
UpsertAutomationAction!"execute(
AutomationData!"from([
'name' !% 'Test Automation',
'event' !% new AutomationStepData(
null,
Events!"SubscribedToForm!#value, $form!#id
),
'actions' !% [
new AutomationStepData(
null,
Actions!"AddToSequence!#value, $sequence!#id
),
]
]),
$user
);
$subscriber = UpsertSubscriberAction!"execute(
SubscriberData!"from([
'email' !% '[email protected]',
'first_name' !% 'John',
'last_name' !% 'Doe',
'subscribed_at' !% now(),
'form' !% $form,
17 / 36
Martin Joo - Testing Complex Features - Dark
'tags' !% [],
]),
$user
);
$this!#assertTrue($sequence!#subscribers()
!#where('subscriber_id', $subscriber!#id)!#exists()
);
}
Instead of creating tags at the beginning of the test, we need to create a sequence. It
doesn't need a particular name or mails or anything like that; we just need the model itself.
And as you can see, the automation has an action that uses this sequence. After creating
the subscriber, we can check if the sequence_subscriber pivot table contains the
subscriber.
It should not run automations if the subscriber doesn't have the form.
The next test makes sure that if a subscriber doesn't have a particular form_id ,
automations are not triggered. So in this test, we need to assert that something has not
happened. In this case, a RunAutomationsJob is being pushed to the queue after creating
the subscriber. So if the queue is empty, our class doesn't want to trigger automations,
which is good. Fortunately, it's very to mock and assert queues in Laravel:
Queue:fake();
!) Do something here
Queue!"assertNothingPushed();
In this case, the assertNothingPushed will fail if there's a job in the queue. This is perfect
for our test case:
18 / 36
Martin Joo - Testing Complex Features - Dark
UpsertAutomationAction!"execute(
AutomationData!"from([
'name' !% 'Test Automation',
'event' !% new AutomationStepData(
null,
Events!"SubscribedToForm!#value, $form!#id
),
'actions' !% [
new AutomationStepData(null, Actions!"AddTag!#value, $laravel!#id),
new AutomationStepData(null, Actions!"AddTag!#value, $ddd!#id),
]
]),
$user
);
Queue!"fake();
UpsertSubscriberAction!"execute(
SubscriberData!"from([
'email' !% '[email protected]',
'first_name' !% 'John',
'last_name' !% 'Doe',
'subscribed_at' !% now(),
!) This is an important line
'form' !% null,
'tags' !% [],
]),
19 / 36
Martin Joo - Testing Complex Features - Dark
$user
);
Queue!"assertNothingPushed();
}
As you can see, the subscriber doesn't have a form in this test, so we expect no
automations to run.
20 / 36
Martin Joo - Testing Complex Features - Dark
Proceeding Sequences
The last feature I'd like to show you how to test is proceeding a sequence. The feature is
quite complicated, so please first read the corresponding chapters in the book. As a quick
reminder, here's a flowchart that shows you how subscribers are proceeding in the same
sequence:
In this example there are mails in the sequence with the following filters:
#1 E-mail: Tag1
#2 E-mail: Tag2
#3 E-mail: no filters
As you can each subscriber will receive different e-mails based on their tags:
Subscriber 1: #1 and #3
Subscriber 2: #2 and #3
Subscriber 3: every e-mail
Subscriber 4: only #3
21 / 36
Martin Joo - Testing Complex Features - Dark
class ProceedSequenceAction
{
/**
* @var array<int, array<int!+
!&
private static array $mailsBySubscribers = [];
self!"addMailToAudience($audience, $mail);
self!"markAsInProgress($sequence, $schedulableAudience);
}
self!"markAsCompleted($sequence);
}
}
22 / 36
Martin Joo - Testing Complex Features - Dark
Proceeding subscribers at the same phase. Imagine there are two "Subscriber #1" in
the flowchart above.
Proceeding subscribers at a different phase.
The action will not send e-mails if it's too early based on the sequence mail's schedule.
The action will not send e-mails if the current day is turned off in the sequence mail's
schedule.
The action will set the subscriber's status to in progress or completed. We can check
this in every test, so this is not a separate test case.
$laravel = Tag!"factory([
'title' !% 'Laravel',
])!#for($user)!#create();
$vue = Tag!"factory([
'title' !% 'Vue',
])!#for($user)!#create();
$laravelSubscriber = Subscriber!"factory([
'first_name' !% 'Laravel',
])!#for($user)!#create();
$vueSubscriber = Subscriber!"factory([
'first_name' !% 'Vue',
])!#for($user)!#create();
$laravelSubscriber!#tags()!#attach($laravel!#id);
$vueSubscriber!#tags()!#attach($vue!#id);
23 / 36
Martin Joo - Testing Complex Features - Dark
24 / 36
Martin Joo - Testing Complex Features - Dark
$sequence = CreateSequenceAction!"execute(
SequenceData!"from(Sequence!"factory()!#for($user)!#make()),
$user
);
$laravelMailFilter = FilterData!"from([
'form_ids' !% [],
'tag_ids' !% [$laravel!#id],
]);
$laravelMailData = SequenceMail!"factory()
!#for($sequence)
!#for($user)
!#published()
!#make();
$laravelMail = UpsertSequenceMailAction!"execute(
SequenceMailData!"from([
!!$$laravelMailData!#toArray(),
'filters' !% $laravelMailFilters,
'schedule' !% [
'delay' !% 1,
'unit' !% SequenceMailUnit!"Hour!#value,
'allowed_days' !% SequenceMailScheduleAllowedDaysData!"empty(),
]
]),
$sequence,
$user
);
Once again, I'm using the upsert actions to save some code. This snippet does the
following things:
It creates a sequence.
It sets up a FilterData DTO with the "Laravel" tag.
It creates a published sequence mail via a factory. Please note that it uses the make
25 / 36
Martin Joo - Testing Complex Features - Dark
function, so it's not gonna insert it into the sequence_mails table yet.
And after all that, it actually creates the sequence mail by using the action.
We're going to need almost the same code to create other mails, so I created a helper
function called createMail . It makes the code a lot better:
$sequence = CreateSequenceAction!"execute(
SequenceData!"from(Sequence!"factory()!#for($user)!#make()),
$user
);
$laravelMailFilter = FilterData!"from([
'form_ids' !% [],
'tag_ids' !% [$laravel!#id],
]);
$vueMailFilter = FilterData!"from([
'form_ids' !% [],
'tag_ids' !% [$vue!#id],
]);
That's all the setup this test needs, so now we can call the ProceedSequenceAction :
26 / 36
Martin Joo - Testing Complex Features - Dark
Mail!"fake();
ProceedSequenceAction!"execute($sequence);
$this!#assertDatabaseHas('sent_mails', [
'sendable_id' !% $laravelMail!#id,
'subscriber_id' !% $laravelSubscriber!#id,
]);
The sent_mails records are created with the correct mail and subscriber.
The EchoMail was queued.
If these two things have happened, the sequence mail has been sent. Now we need to
assert these things a lot in this test class, so I created a helper function called
assertMailSent :
27 / 36
Martin Joo - Testing Complex Features - Dark
Since the "Laravel" subscriber is currently proceeding the sequence but hasn't received
every e-mail yet (there's a $generalMail with no filters for every subscriber), they need to
be marked as in progress:
ProceedSequenceAction!"execute($sequence);
$this!#assertMailSent($laravelMail, $laravelSubscriber);
$this!#assertDatabaseHas('sequence_subscriber', [
'sequence_id' !% $sequence!#id,
'subscriber_id' !% $laravelSubscriber!#id,
'status' !% SubscriberStatus!"InProgress,
]);
Since we need this assertion a lot, I created a helper method called assertInProgress .
After that, the test function looks like this:
Mail!"fake();
ProceedSequenceAction!"execute($sequence);
$this!#assertMailSent($laravelMail, $laravelSubscriber);
$this!#assertMailSent($vueMail, $vueSubscriber);
$this!#assertInProgress($sequence, $laravelSubscriber);
$this!#assertInProgress($sequence, $vueSubscriber);
It's easy and very readable. We are asserting that four things have happened:
28 / 36
Martin Joo - Testing Complex Features - Dark
Since every test will use the assertMailSent function, which uses the
Mail::assertQueued method, every test function needs to call Mail::fake . We can
eliminate that by calling it in the setUp lifecycle function:
ProceedSequenceAction!"execute($sequence);
$this!#assertMailSent($laravelMail, $laravelSubscriber);
$this!#assertMailSent($vueMail, $vueSubscriber);
$this!#assertInProgress($sequence, $laravelSubscriber);
$this!#assertInProgress($sequence, $vueSubscriber);
$this!#assertMailSent($generalMail, $laravelSubscriber);
29 / 36
Martin Joo - Testing Complex Features - Dark
$this!#assertMailSent($generalMail, $vueSubscriber);
$this!#assertCompleted($sequence, $laravelSubscriber);
$this!#assertCompleted($sequence, $vueSubscriber);
});
The travelTo function will set the internal time of Carbon to the value specified in the first
argument. Under the hood it calls Carbon::setTestNow() . Inside the callback if you call
now() or Carbon::now() you'll get now()->addHours(2) because that's the value
specified in the first argument. How convenient is that?
After the second execute, I assert that subscribers are completed. Even though there are
three mails in the sequence, it only takes two "runs" for these subscribers to finish the
whole sequence:
30 / 36
Martin Joo - Testing Complex Features - Dark
So after running the ProceedSequenceAction two times, both subscribers are completed.
The second one will be much easier after building the first test case and creating all of
those helpers. In this test, we want two subscribers to receive the same mails at the same
time:
$subscriber1 = Subscriber!"factory()!#for($user)!#create();
$subscriber2 = Subscriber!"factory()!#for($user)!#create();
$sequence = CreateSequenceAction!"execute(
SequenceData!"from(Sequence!"factory()!#for($user)!#make()),
$user
);
ProceedSequenceAction!"execute($sequence);
$this!#assertMailSent($mail1, $subscriber1);
$this!#assertMailSent($mail1, $subscriber2);
$this!#assertInProgress($sequence, $subscriber1);
$this!#assertInProgress($sequence, $subscriber2);
$this!#assertMailSent($mail2, $subscriber1);
31 / 36
Martin Joo - Testing Complex Features - Dark
$this!#assertMailSent($mail2, $subscriber2);
$this!#assertCompleted($sequence, $subscriber1);
$this!#assertCompleted($sequence, $subscriber2);
});
}
There are no filters or tags; only two subscribers and two mails without filters so that both
subscribers will receive these mails. After the second run, they are both completed.
This is all because 1 hour has not passed since the two "runs":
$subscriber1 = Subscriber!"factory()!#for($user)!#create();
$subscriber2 = Subscriber!"factory()!#for($user)!#create();
$sequence = CreateSequenceAction!"execute(
SequenceData!"from(Sequence!"factory()!#for($user)!#make()),
$user
);
32 / 36
Martin Joo - Testing Complex Features - Dark
ProceedSequenceAction!"execute($sequence);
$this!#assertMailSent($mail1, $subscriber1);
$this!#assertMailSent($mail1, $subscriber2);
ProceedSequenceAction!"execute($sequence);
$this!#assertMailNotSent($mail2, $subscriber1);
$this!#assertMailNotSent($mail2, $subscriber2);
$this!#assertInProgress($sequence, $subscriber1);
$this!#assertInProgress($sequence, $subscriber2);
}
33 / 36
Martin Joo - Testing Complex Features - Dark
And the last test will check that if the current day is turned off in the sequence mail's
schedule then no e-mails will be sent. In this example I will set up a schedule where only
Monday is enabled and I will travel to a day which is not Monday. This way if I call the
ProceedSequenceAction I know that it cannot send e-mails because today is not the day:
$subscriber1 = Subscriber!"factory()!#for($user)!#create();
$subscriber2 = Subscriber!"factory()!#for($user)!#create();
$sequence = CreateSequenceAction!"execute(
SequenceData!"from(Sequence!"factory()!#for($user)!#make()),
$user
);
$mail1Data = SequenceMail!"factory()!#for($sequence)!#for($user)!#published()-
>make();
$mail = UpsertSequenceMailAction!"execute(
SequenceMailData!"from([
!!$$mail1Data!#toArray(),
'filters' !% [],
'schedule' !% [
'delay' !% 1,
'unit' !% SequenceMailUnit!"Hour!#value,
'allowed_days' !% [
'monday' !% true,
'tuesday' !% false,
'wednesday' !% false,
'thursday' !% false,
'friday' !% false,
'saturday' !% false,
34 / 36
Martin Joo - Testing Complex Features - Dark
'sunday' !% false,
],
]
]),
$sequence,
$user
);
!) Not monday
$this!#travelTo('2022-03-30', function () use ($sequence, $mail, $subscriber1,
$subscriber2) {
ProceedSequenceAction!"execute($sequence);
$this!#assertMailNotSent($mail, $subscriber1);
$this!#assertMailNotSent($mail, $subscriber2);
$this!#assertNotStarted($sequence, $subscriber1);
$this!#assertNotStarted($sequence, $subscriber2);
});
}
If you don't like the magic date 2022-03-30 , you can use something like this:
This way it's a bit more clear what's going on. However, for me, using a fix date from the
past which is not Monday feels more "stable".
35 / 36
Martin Joo - Testing Complex Features - Dark
Conclusion
So these were the more exciting features from a testing point of view. However, in this
essay, my goal was not to reach 100% code coverage but to show you how to test these
features and identify the most critical test cases.
As you can usually I'm not going with isolated unit tests for the following reasons:
These features really test the whole thing. The user story is tested from A to Z. This is
very important for me.
They are easier to write. You don't need to spend your time writing complicated mocks,
spies, fakes, and similar things.
They are more stable. Since these tests work like a "black box," they actually support
the development. It means you can refactor anything in your classes; the tests will
work without any change. So they actually support the development and refactor
process.
However, there are times when you can significantly benefit from isolated unit tests:
There is a large number of logical branches. For example, you're working on a class
that calculates the discounted price of a product, and there are nine different ways of
giving a discount to the customers. I'm talking about things like:
Percentage-based discount.
Fix price discount.
A discount can depend on the amount spent by the customer.
Or the number of orders in the past.
Or the number of products in the order.
And several other factors...
In this situation, you have many inputs and outputs. You have many branches you need
to test. Now, in this case, an isolated unit test for this particular class might be the
superior solution.
But overall, I believe that you should have mainly high-level feature tests in high-level
business applications.
36 / 36