Describes the recommended workflow in steps in order to build a feature with the most efficient use of time, showcasing how Lucid enhances productivity and efficiency.
Building applications is as similar in concept as building any artefact made of multiple pieces such as houses, towers, castles etc. which are all built on the basis of the same construct - stone walls. For that we need material, structure, measurements and most importantly sequence; for we need the base layer of stones and cement to build upon, and it is built row by row.
In this guide we show the recommended process of building robust (yet flexible) blocks that make our application whole, ensuring ideal levels of productivity and efficiency by outlining the composition and interaction of our components prior to indulging into implementation - Thinking time as described in Peopleware is the objective of this workflow.
Writing Features
Generate a Feature class, which will create its test class along.
Open the Feature class and describe the steps to achieve the feature's functionality in TODO items such as:
This approach gives the benefit of an organised and defined workflow, it enhances productivity, focus and visibility by summarising the steps as an overview of what it is to be implemented. In addition to the benefit of editors listing those when needed:
Start by writing the test for the feature: it is a request to call the route with one of the acceptance cases with input params (if applicable) to execute this feature. At this step, we will have to create the route, controller and controller method to serve the feature, and add the serve(UpdateResourceFeature::class) line to the controller method.
Now that we've written our first test case to execute the feature, it sure will fail because our Feature class is still empty. Now, we can start filling this feature with jobs by replacing each of these steps with the Job or Operation class that fulfils it, following the workflows below.
Writing Operations
Similar to Features, Operations group multiple Jobs that are often executed together, or their combination form a single piece of functionality that itself can be split into multiple reusable steps. However, they differ in testing.
Generate an Operation class, which will create its test class along.
Open the Operation class and describe the steps that implement the functionality in comments
classNotifyFollowersOperationextendsOperation {publicfunctionhandle(Request $request) {// 1. @TODO: query followers IDs (paginate)// 2. @TODO: enqueue notifications in batches of 50// 3. @TODO: return status } }
Start writing the test for the operation: It is a unit test to ensure one thing:
That it runs the correct jobs with the correct parameters, in the correct order.
Jobs don't really run within an Operation test, we rely on the unit test of the single job to ensure its integrity, while operations are about running multiple jobs in sequence. We achieve this by partially mocking the Operation class.
With this we have drawn a map of how we would like our codebase to be composed on the outside, and what is expected from each component for this implementation. It is now that we start detecting reusable components and we adjust as desired. For example we might figure that a similar functionality is already implemented and needs a simple adjustment to be used in the current operation or feature, so we go ahead and include a @TODO item to update it.
Of course, this is an iterative process where we rarely get things right from the first time so feel free to draft as many possibilities until you are satisfied with the final result, then start filling Job classes with implementation.
At this stage we would have had to create our Job classes so that we cloud use them in the test of our Operation and Feature classes, next will be to fill those Jobs with implementation.
Writing Jobs
Resulting from our previous steps of the workflow, a list of tasks that we can start ticking off and our feature will be implemented completely by the end. All that is left is to start filling Job classes with implementations.
If you hadn't generated the Job class previously, generate a Job class for each of the items previously marked as a "to do" item. It is usually best to generate one class at a time, fill it, ensure that it works as expected and that it provides the functionality required and then move on to the next. As for the sequence of generating the jobs, it is usually best to follow the sequence in the Feature class.
Open the generated Job class and start writing functionality. If you are into TDD, you may prefer to open the test file and write your expectations first. Nevertheless, it is not important nor does it affect the outcome to whether you write the test or the implementation first; whichever suites you best.
namespaceApp\Domains\Article\Tests\Jobs;useApp\Domains\Article\Jobs\ValidateArticleInputJob;useLucid\Foundation\Validator;useTests\TestCase;classValidateArticleInptJobTestextendsTestCase{publicfunctiontest_validate_article_input_job() { $job =newValidateArticleInputJob(['title'=>'The Title','content'=>'The content of the article goes here.', ]);$this->assertTrue($job->handle(app(Validator::class))); }/** * @dataProvider articleInputValidationProvider * @expectedException \Lucid\Foundation\InvalidInputException */publicfunctiontest_validating_article_input_job_rules($title =null, $content =null) { $job =newValidateArticleInputJob(compact('title','content')); $job->handle(app(Validator::class)); }publicfunctionarticleInputValidationProvider() {return ['without title'=> ['content'=>'The content of the article.', ],'title is empty'=> ['title'=>'','content'=>'The content of the article.', ],'without content'=> ['title'=>'The Title Only', ],'content is empty'=> ['title'=>'The Title Here','content'=>'', ],'max title length'=> ['title'=>str_repeat('a',101),'content'=>'Content goes here', ],'max content length'=> ['title'=>'Title here','content'=>str_repeat('a',1001), ], ]; }}
Running the tests now will yield the following results
PHPUnit 7.5.8 by Sebastian Bergmann and contributors........I..10/10 (100%)Time: 351 ms, Memory: 18.00 MBOK, but incomplete, skipped,or risky tests!Tests: 10, Assertions: 9, Incomplete: 1.
I know, the incomplete test is annoying, but it is there for a purpose! This is the Feature test, telling us that we're not done yet. Lucid test files are initially generated with a markTestIncomplete for that purpose.
Step 2 - Authorise Access
// 2. @TODO: verify access to resource
Generate Job with lucid make:job AuthoriseArticleUpdateJob Article
This job will require some setup before we can dive into it since it deals with database. We will use SQLite for testing, which will work as-is with any other SQL database, thanks to Eloquent.
Update phpunit.xml to run the tests in an SQLite memory instance
A fix is required for factories to work, go to database/factories/ModelFactory.php and change the namespace of User::class to App\Data\Models\User::class
Generate a migration to create our Article model's table
This job simply fetches the user with the intended article as a relationship with a firstOrFail which will throw a ModelNotFoundException if the record is not found, indicating that either one of the records doesn't exist (user or article) or the relationship between them doesn't exist, meaning that the intended article doesn't belong to this user. Then we write the test to ensure this functionality serves as intended.
namespaceApp\Domains\Article\Tests\Jobs;useApp\Data\Models\Article;useApp\Data\Models\User;useIlluminate\Foundation\Testing\RefreshDatabase;useIlluminate\Foundation\Testing\DatabaseMigrations;useApp\Domains\Article\Jobs\AuthoriseArticleUpdateJob;useTests\TestCase;classAuthoriseArticleUpdateJobTestextendsTestCase{useRefreshDatabase;useDatabaseMigrations;publicfunctiontest_authorise_article_update_job() { $article =factory(Article::class)->create(); $user = $article->user; $job =newAuthoriseArticleUpdateJob($user, $article->id);// this usually throws ModelNotFoundException,// if the user was not found. Not having anything returned is a good sign$this->assertNull($job->handle()); }/** * @expectedException \Illuminate\Database\Eloquent\ModelNotFoundException */publicfunctiontest_fails_authorisation_for_different_user() { $article =factory(Article::class)->create(); $anotherUser =factory(User::class)->create(); $job =newAuthoriseArticleUpdateJob($anotherUser, $article->id);// must throw ModelNotFoundException $job->handle(); }}
Step 3 - Save Article
// 3. @TODO: update resource
Generate Job lucid make:job SaveArticle Article
Saving an article is simple, we only need its identifier and the new attributes.
app/Domains/Article/Jobs/SaveArticleJob.php
namespaceApp\Domains\Article\Jobs;useLucid\Foundation\Job;useApp\Data\Models\Article;classSaveArticleJobextendsJob{/** * @varstring */private $title;/** * @varstring */private $content;/** * @varint */private $articleId;/** * Create a new job instance. * * @paramint $articleId * @paramstring $title * @paramstring $content */publicfunction__construct(int $articleId,string $title,string $content) {$this->title = $title;$this->content = $content;$this->articleId = $articleId; }/** * Execute the job. * * @returnint The number of records that were changed. * It will be 1 if update was successful. * Otherwise it's a 0. */publicfunctionhandle() :int {returnArticle::where('id',$this->articleId)->update(['title'=>$this->title,'content'=>$this->content, ]); }}
tests/Domains/Article/Jobs/SaveArticleJobTest.php
namespaceApp\Domains\Article\Tests\Jobs;useApp\Data\Models\Article;useApp\Domains\Article\Jobs\SaveArticleJob;useTests\TestCase;useIlluminate\Foundation\Testing\RefreshDatabase;useIlluminate\Foundation\Testing\DatabaseMigrations;classSaveArticleJobTestextendsTestCase{useRefreshDatabase;useDatabaseMigrations;publicfunctiontest_save_article_job() { $title ='New Unique Title'; $content ='Replaced content with this new piece.'; $article =factory(Article::class)->create(); $job =newSaveArticleJob($article->id, $title, $content);// we expect one record to be updateed only.$this->assertEquals(1, $job->handle()); }}
Step 4 - Add Audit Entry Asynchronously
// 4. @TODO: save audit activity (async)
This is a fancy step that in most cases won't be needed, but it's been added to the workflow to showcase working with async using Laravel queues. Logging activity is simply adding a record to an activity database table, but adding activity should not be a blocking action for it is not required by the response to the user, to whom we should be responding as quick as possible.
You will notice that the generated job extends QueueableJob instead of Job which will instruct the dispatcher when calling $this->run(Job) in the Feature or Operation to run it asynchronously in Laravel queue as configured in the application.
Notifying followers here is meant as if every follower of this author is subscribed to receive some sort of a notification every time this author publishes a new post. It is made of two steps:
Fetching a user's followers
Sending them notifications
Similar to our activity log, since it is not essential for the response to the user, we better do these steps asynchronously.
Since each of these steps should be in its own Job according to the Lucid principles, and so that we don't run two jobs every time we need to notify followers, Operations are handy in this scenario. But we will follow an outward approach where we start by creating the jobs and ensuring that they work as expected, then create the operation that runs them, just as we are doing with our Feature here. This approach is recommended because after finishing with the jobs we will know what to the Operation should expect as input parameters based on what the jobs require.
Generate Operation to run both jobs lucid make:operation NotifyFollowers --queue
And its test. The objective of testing operations is to ensure they run the correct jobs with the correct parameters in the correct sequence. It is not necessary to run the actual job because we would be testing the job twice while we should be keen not to reinvent the wheel nor replicating tests, we rely on the Job's tests to provide its guarantee.
Simply return a JSON response using built-in Http\RespondWithJsonJob as seen in the next step.
Step 7 - Finally: Compose Feature
Now that we have all the jobs ready and we're sure that they are working as expected through their unit tests, all we have to do now is run them in sequence in our Feature class and pass the correct parameters and everything should work as expected.
namespaceApp\Tests\Features;useTests\TestCase;useApp\Data\Models\User;useApp\Data\Models\Article;useApp\Features\UpdateArticleFeature;useIlluminate\Foundation\Testing\RefreshDatabase;useIlluminate\Foundation\Testing\DatabaseMigrations;classUpdateArticleFeatureTestextendsTestCase{useRefreshDatabase;useDatabaseMigrations;publicfunctiontest_updating_article_feature() { $article =factory(Article::class)->create(); $user = $article->user;$this->actingAs($user)->put("/articles/$article->id", ['title'=>'My Updated New Title','content'=>'Shiny shiny, shiny new content.', ])->assertJson(['status'=>200,'data'=> ['success'=>true, ] ]); $retrieved =Article::find($article->id);$this->assertEquals('My Updated New Title', $retrieved->title);$this->assertEquals('Shiny shiny, shiny new content.', $retrieved->content); }/** * @expectedException \Illuminate\Database\Eloquent\ModelNotFoundException */publicfunctiontest_updating_article_fails_when_unauthorised() { $article =factory(Article::class)->create(); $anotherUser =factory(User::class)->create();$this->actingAs($anotherUser)->put("/articles/$article->id", ['title'=>'My Updated New Title','content'=>'Shiny shiny, shiny new content.', ])->assertJson(['status'=>200,'data'=> ['success'=>true, ] ]); }}
Notable Observation
Through this workflow, it is important to note how tests cover the scope of the component only and keeps us from breaking the boundaries where we shouldn't. Especially when it comes to the feature test where it is kept to what it is responsible for, without having to repeat the tests that's been covered by the jobts themselves. However, it might be important to test edge cases at the feature level to ensure that the responses from our backend are consistent and as expected.
FAQ
Why sometimes we use UserId and other times the whole User?
Because of the scope. For example: AuthoriseArticleUpdateJob might require more information about the user in the future than their ID because the nature of what it does - authorisation. While other jobs such as LogActivityJob and NotifyFollowersOperation are only concerned about the user's identifier, regardless of where and how this was retrieved.
Doing this allows for the reuse of these jobs beyond the scope of this feature alone. We can use them anywhere else (for example from a command where users aren't authenticated) without having to workaround authentication where it doesn't belong.
Could we have improved logging activity?
Yes. For example, create a job for each activity (i.e. LogArticleUpdateActivityJob instead of a generic LogActivityJob. It would be preferred over having a string in the feature which is recommended to be avoided when possible but kept here for the simplicity of this exercise.
Can we add more safety to this code?
Yes, plenty! A simple addition would be to ensure there's a session before proceeding with the feature to avoid errors such as:
Symfony\Component\Debug\Exception\FatalThrowableError: Argument 1 passed to App\Domains\Article\Jobs\AuthoriseArticleUpdateJob::__construct()must be an instance of App\Data\Models\User,null given
This could be done around the HTTP Kernel like in the middleware area.
Another safety check would be to wrap async calls with a try/catch block so that failures in there don't affect the user's response (namely LogActivityJob and NotifyFollowersOperation because they do not dictate the actual status of the update.