This article was originally written by
Wern Ancheta on the Honeybadger Developer Blog.
In this tutorial, I’ll show you how to get started with test-driven development in Laravel by creating a project from scratch. After following this tutorial, you should be able to apply test-driven development in any future Laravel projects. Additionally, the concepts you will learn in this tutorial should also be applicable to other programming languages.
We’ll create a food ordering app at its most basic level. It will only have the following features:
- Search for food
- Add food to cart
- Submit order (Note that this won’t include the processing of payments. Its only purpose is saving order information in the database.)
Prerequisites
- PHP development environment (including PHP and MySQL). Apache or nginx is optional since we can run the server via Laravel’s artisan command.
- Node (this should include npm).
- Basic PHP and Laravel experience.
Overview
We'll cover the following topics:
- The TDD workflow
- How to write tests using the 3-phase pattern
- Assertions
- Refactoring your code
Setting Up a Laravel Project
To follow along, you’ll need to clone the GitHub repo and switch to the starter
branch:
git clone git@github.com:anchetaWern/food-order-app-laravel-tdd.git
cd food-order-app-laravel-tdd
git checkout starter
Next, install the composer dependencies:
composer install
Rename the .env.example
file to .env
and generate a new key:
php artisan key:generate
Next, install the frontend dependencies:
npm install
Once that’s done, you should be able to run the project:
php artisan serve
Project Overview
You can access the project on the browser to have an idea what we’re going to build:
http://127.0.0.1:8000/
First, we have the search page where users can search for the food they’re going to order. This is the app's default page:
Next, we have the cart page where users can see all the food they’ve added to their cart. From here, they can also update the quantity or remove an item from their cart. This is accessible via the /cart
route:
Next, we have the checkout page where the user can submit the order. This is accessible via the /checkout
route:
Lastly, we have the order confirmation page. This is accessible via the /summary
route:
Building the Project
In this tutorial, we’ll be focusing solely on the backend side of things. This will allow us to cover more ground when it comes to the implementation of TDD in our projects. Thus, we won’t be covering any of the frontend aspects, such as HTML, JavaScript, or the CSS code. This is why it’s recommended that you start with the starter
branch if you want to follow along. The rest of the tutorial will assume that you have all the necessary code in place.
The first thing you need to keep in mind when starting a project using TDD is that you have to write the test code before the actual functionality that you need to implement.
This is easier said than done, right? How do you test something when it doesn’t even exist yet? This is where “coding by wishful thinking” comes in. The idea is to write test code as if the actual code you are testing already exists. Therefore, you’re basically just interacting with it as if it’s already there. Afterward, you run the test. Of course, it will fail, so you need to use the error message to guide you on what needs to be done next. Just write the simplest implementation to solve the specific error returned by the test and then run the test again. Repeat this step over until the test passes.
It’s going to feel a bit weird when you’re just starting out, but you’ll get used to it after writing a few dozen tests and going through the whole cycle.
Creating a Test
Let’s proceed by creating a new test file. We will be implementing each screen individually in the order that I showed earlier.
php artisan make:test SearchTest
This will create a SearchTest.php
file under the /tests/Feature
folder. By default, the make:test
artisan command creates a feature test. This will test a particular feature rather than a specific bit of code. The following are some examples:
- Test whether a user is created when a signup form with valid data is submitted.
- Test whether a product is removed from the cart when the user clicks on the remove button.
- Test whether a specific result is listed when the user inputs a specific query.
However, unit tests are used for digging deeper into the functionality that makes a specific feature work. This type of test interacts directly with the code involved in implementing a specific feature. For example, in a shopping cart feature, you might have the following unit tests:
- Calling the
add()
method in theCart
class adds an item to the user’s cart session. - Calling the
remove()
method in theCart
class removes an item from the user’s cart session.
When you open the tests/Feature/SearchTest.php
file, you’ll see the following:
<?php
// tests/Feature/SearchTest.php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;
class SearchTest extends TestCase
{
/**
* A basic feature test example.
*
* @return void
*/
public function test_example()
{
$response = $this->get('/');
$response->assertStatus(200);
}
}
This will test whether accessing the app’s homepage will return a 200
HTTP status code, which basically means the page is accessible by any user when visiting the site on a browser.
Running a Test
To run tests, execute the following command; this will run all the tests using the PHP Unit test runner:
vendor/bin/phpunit
This will return the following output. There’s already a default feature and unit test in addition to the test we just created, which is why there are 3 tests and 3 assertions:
PHPUnit 9.5.11 by Sebastian Bergmann and contributors.
... 3 / 3 (100%)
Time: 00:00.219, Memory: 20.00 MB
OK (3 tests, 3 assertions)
Delete the default ones in the tests/Feature
and tests/Unit
folders, as we won’t be needing them.
If you want to run a specific test file, you can supply the --filter
option and add either the class name or the method name:
vendor/bin/phpunit --filter SearchTest
vendor/bin/phpunit --filter test_example
This is the only command you need to remember to get started. Obviously, it’s hard to type out the whole thing over and over again, so add an alias instead. Execute these two commands while inside the project’s root directory:
alias p='vendor/bin/phpunit'
alias pf='vendor/bin/phpunit --filter'
Once that’s done, you should be able to do this instead:
p
pf Searchtest
pf test_example
Search Test
Now we’re finally ready to write some actual tests for the search page. Clear out the existing tests so that we can start with a clean slate. Your tests/Feature/SearchTest.php
file should look like this:
<?php
// tests/Feature/SearchTest.php
namespace Tests\Feature;
use Tests\TestCase;
// this is where we'll import the classes needed by the tests to run
class SearchTest extends TestCase
{
// this is where we'll write some test code
}
To start, let’s write a test to determine whether the homepage is accessible. As you learned earlier, the homepage is basically the food search page. There are two ways you can write test methods; the first is by adding the test annotation:
// tests/Feature/SearchTest.php
/** @test */
public function food_search_page_is_accessible()
{
$this->get('/')
->assertOk();
}
Alternatively, you can prefix it with the word test_
:
// tests/Feature/SearchTest.php
public function test_food_search_page_is_accessible()
{
$this->get('/')
->assertOk();
}
Both ways can then be executed by supplying the method name as the value for the filter. Just omit the test_
prefix if you went with the alternative way:
pf food_search_page_is_accessible
For consistency, we’ll use the /** @test */
annotation for the rest of the tutorial. The advantage of this is you're not limited to having the word "test" in your test method names. That means you can come up with more descriptive names.
As for naming your test methods, there is no need to overthink it. Just name it using the best and most concise way to describe what you’re testing. These are only test methods, so you can have a very long method name, as long as it clearly describes what you’re testing.
If you have switched over to the starter
branch of the repo, you’ll see that we already put the necessary code for the test to pass:
// routes/web.php
Route::get('/', function () {
return view('search');
});
The next step is to add another test that proves the search page has all the necessary page data. This is where the 3-phase pattern used when writing tests comes in:
- Arrange
- Act
- Assert
Arrange Phase
First, we need to “arrange” the world in which our test will operate. This often includes saving the data required by the test in the database, setting up session data, and anything else that’s necessary for your app to run. In this case, we already know that we will be using a MySQL database to store the data for the food ordering app. This is why, in the “arrange” phase, we need to add the food data to the database. This is where we can put “coding by wishful thinking” to the test (no pun intended).
At the top of your test file (anywhere between the namespace and the class), import the model that will represent the table where we will store the products to be displayed in the search page:
// tests/Feature/SearchTest.php
use Tests\TestCase;
use App\Models\Product; // add this
Next, create another test method that will create 3 products in the database:
// tests/Feature/SearchTest.php
/** @test */
public function food_search_page_has_all_the_required_page_data()
{
// Arrange phase
Product::factory()->count(3)->create(); // create 3 products
}
Run the test, and you should see an error similar to the following:
1) Tests\Feature\SearchTest::food_search_page_has_all_the_required_page_data
Error: Class 'App\Models\Product' not found
From here, all you need to do is try to solve the error with as minimal effort as possible. The key here is to not do more than what’s necessary to get rid of the current error. In this case, all you need to do is generate the Product
model class and then run the test again.
php artisan make:model Product
It should then show you the following error:
1) Tests\Feature\SearchTest::food_search_page_has_all_the_required_page_data
Error: Class 'Database\Factories\ProductFactory' not found
Again, just do the minimum step required and run the test again:
php artisan make:factory ProductFactory
At this point, you should get the following error:
There was 1 error:
1) Tests\Feature\SearchTest::food_search_page_has_all_the_required_page_data
Illuminate\Database\QueryException: SQLSTATE[HY000] [1049] Unknown database 'laravel' (SQL: insert into `products` (`updated_at`, `created_at`) values (2022-01-20 10:29:26, 2022-01-20 10:29:26))
...
Caused by
PDOException: SQLSTATE[HY000] [1049] Unknown database 'laravel'
It makes sense because we haven’t set up the database yet. Go ahead and update the project’s .env
file with the correct database credentials:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=food_order
DB_USERNAME=root
DB_PASSWORD=
You also need to create the corresponding database using your database client. Once that’s done, run the test again, and you should get the following error:
1) Tests\Feature\SearchTest::food_search_page_has_all_the_required_page_data
Illuminate\Database\QueryException: SQLSTATE[42S02]: Base table or view not found: 1146 Table 'food_order.products' doesn't exist (SQL: insert into `products` (`updated_at`, `created_at`) values (2022-01-20 10:36:11, 2022-01-20 10:36:11))
Caused by
PDOException: SQLSTATE[42S02]: Base table or view not found: 1146 Table 'food_order.products' doesn't exist
The logical next step is to create a migration:
php artisan make:migration create_products_table
Obviously, the migration won’t run on its own, and a table needs some fields to be created. Therefore, we need to update the migration file first and run it before running the test again:
// database/migrations/{datetime}_create_products_table.php
public function up()
{
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->float('cost');
$table->string('image');
$table->timestamps();
});
}
Once you’re done updating the migration file:
php artisan migrate
Now, after running the test, you should see the following error:
1) Tests\Feature\SearchTest::food_search_page_has_all_the_required_page_data
Illuminate\Database\QueryException: SQLSTATE[HY000]: General error: 1364 Field 'name' doesn't have a default value (SQL: insert into `products` (`updated_at`, `created_at`) values (2022-01-20 10:49:52, 2022-01-20 10:49:52))
Caused by
PDOException: SQLSTATE[HY000]: General error: 1364 Field 'name' doesn't have a default value
This brings us to the Product factory, which we left with the defaults earlier. Remember, in the test we’re using the Product factory to create the necessary data for the “arrange” phase. Update the Product factory so it generates some default data:
<?php
// database/factories/ProductFactory.php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use App\Models\Product;
class ProductFactory extends Factory
{
protected $model = Product::class;
/**
* Define the model's default state.
*
* @return array
*/
public function definition()
{
return [
'name' => 'Wheat',
'cost' => 2.5,
'image' => 'some-image.jpg',
];
}
}
After saving the changes, run the test again, and you should see this:
1) Tests\Feature\SearchTest::food_search_page_has_all_the_required_page_data
This test did not perform any assertions
OK, but incomplete, skipped, or risky tests!
Tests: 1, Assertions: 0, Risky: 1.
Act Phase
This signals to us that all the required setup necessary to get the app running is already completed. We should now be able to proceed with the “act” phase. This phase is where we make the test perform a specific action to test functionality. In this case, all we need to do is visit the homepage:
// tests/Feature/SearchTest.php
/** @test */
public function food_search_page_has_all_the_required_page_data()
{
// Arrange
Product::factory()->count(3)->create();
// Act
$response = $this->get('/');
}
Assertion Phase
There’s no point in running the test again, so go ahead and add the “assertion” phase. This is where we test whether the response from the “act” phase matches what we expect. In this case, we want to prove that the view being used is the search
view and that it has the required items
data:
// tests/Feature/SearchTest.php
// Assert
$items = Product::get();
$response->assertViewIs('search')->assertViewHas('items', $items);
After running the test, you’ll see our first real issue that doesn’t have anything to do with app setup:
1) Tests\Feature\SearchTest::food_search_page_has_all_the_required_page_data
null does not match expected type "object".
Again, to make the test pass, only invest the least amount of effort required:
// routes/web.php
Route::get('/', function () {
$items = App\Models\Product::get();
return view('search', compact('items'));
});
At this point, you’ll now have your first passing test:
OK (1 test, 2 assertions)
Refactor the code
The next step is to refactor your code. We don’t want to put all our code inside the routes file. Once a test is passing, the next step is refactoring the code so that it follows coding standards. In this case, all you need to do is create a controller:
php artisan make:controller SearchProductsController
Then, in your controller file, add the following code:
<?php
// app/Http/Controllers/SearchProductsController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Product;
class SearchProductsController extends Controller
{
public function index()
{
$items = Product::get();
return view('search', compact('items'));
}
}
Don’t forget to update your routes file:
// routes/web.php
use App\Http\Controllers\SearchProductsController;
Route::get('/', [SearchProductsController::class, 'index']);
We’ve just gone through the whole process of implementing new features using TDD. At this point, you now have a good idea of how TDD is done. Thus, I’ll no longer be walking you through like I did above. My only purpose for doing that is to get you going with the workflow. From here on out, I’ll only be explaining the test code and the implementation without going through the whole workflow.
The previous test didn’t prove that the page presents the items that the user needs to see. This test allows us to prove it:
// tests/Feature/SearchTest.php
/** @test */
public function food_search_page_shows_the_items()
{
Product::factory()->count(3)->create();
$items = Product::get();
$this->get('/')
->assertSeeInOrder([
$items[0]->name,
$items[1]->name,
$items[2]->name,
]);
}
For the above test to pass, simply loop through the $items
and display all the relevant fields:
<!-- resources/views/search.blade.php -->
<div class="mt-3">
@foreach ($items as $item)
<div class="card mb-3 overflow-hidden" style="max-width: 540px; max-height: 145px;">
<div class="row g-0">
<div class="col-md-4">
<img src="{{ $item->image }}" class="img-fluid rounded-start" alt="{{ $item->name }}">
</div>
<div class="col-md-8">
<div class="card-body">
<h5 class="card-title m-0 p-0">{{ $item->name }}</h5>
<span>${{ $item->cost }}</span>
<div class="mt-2">
<button type="button" class="btn btn-primary">Add</button>
</div>
</div>
</div>
</div>
</div>
@endforeach
</div>
The last thing we need to test for on this page is the search functionality. Thus, we need to hard-code the food names in the arrange phase. We can just as easily refer to them by index, like we did in the previous test. You will save a lot of keystrokes by doing that, and it will also be a perfectly valid test. However, most of the time, you will need to think of the person viewing this code later on. What’s the best way to present the code so that he or she will easily understand what you’re trying to test? In this case, we’re trying to test whether a specific item would show up on the page, so it’s better to hard-code the names in the test so it that can be easily visualized:
// tests/Feature/SearchTest.php
/** @test */
public function food_can_be_searched_given_a_query()
{
Product::factory()->create([
'name' => 'Taco'
]);
Product::factory()->create([
'name' => 'Pizza'
]);
Product::factory()->create([
'name' => 'BBQ'
]);
$this->get('/?query=bbq')
->assertSee('BBQ')
->assertDontSeeText('Pizza')
->assertDontSeeText('Taco');
}
Additionally, you also want to assert that the whole item list can still be seen when a query isn’t passed:
// tests/Feature/SearchTest.php
$this->get('/')->assertSeeInOrder(['Taco', 'Pizza', 'BBQ']);
You can then update the controller to filter the results if a query is supplied in the request:
Then, in the controller file, update the code so that it makes use of the query to filter the results:
// app/Http/Controllers/SearchProductsController.php
public function index()
{
$query_str = request('query');
$items = Product::when($query_str, function ($query, $query_str) {
return $query->where('name', 'LIKE', "%{$query_str}%");
})->get();
return view('search', compact('items', 'query_str'));
}
Don’t forget to update the view so that it has a form for accepting the user’s input:
<!-- resources/views/search.blade.php -->
<form action="/" method="GET">
<input class="form-control form-control-lg" type="query" name="query" value="{{ $query_str ?? '' }}" placeholder="What do you want to eat?">
<div class="d-grid mx-auto mt-2">
<button type="submit" class="btn btn-primary btn-lg">Search</button>
</div>
</form>
<div class="mt-3">
@foreach ($items as $item)
...
That’s one of the weakness of this kind of test, because it doesn’t make it easy to verify that a form exists in the page. There’s the assertSee
method, but verifying with HTML isn’t recommend since it might frequently be updated based on design or copy changes. For these types of tests, you’re better off using Laravel Dusk instead. However, that’s out of the scope of this tutorial.
Separate the Testing Database
Before we proceed, notice that the database just continued filling up with data. We don’t want this to happen since it might affect the results of the test. To prevent issues caused by an unclean database, we want to clear the data from the database before executing each test. We can do that by using the RefreshDatabase
trait, which migrates your database when you run the tests. Note that it only does this once and not for every single test. Instead, for every test it will include all of the database calls you make in a single transaction. It then rolls it back before running each test. This effectively undos the changes made in each test:
// tests/Feature/SearchTest.php
use Illuminate\Foundation\Testing\RefreshDatabase; // add this
// the rest of the imports..
class SearchTest extends TestCase
{
use RefreshDatabase;
// the rest of the test file..
}
Try running all your tests again, and notice that your database is empty by the end of it.
Again, this is not ideal because you may want to test your app manually through the browser. Having all the data cleared out all the time would be a pain when testing manually.
Thus, the solution is to create a separate database. You can do this by logging into the MySQL console:
mysql -u root -p
Then, create a database specifically intended for testing:
CREATE DATABASE food_order_test;
Next, create a .env.testing
file on the root of your project directory and enter the same contents as your .env
file. The only thing you need to change is the DB_DATABASE
config:
DB_DATABASE=food_order_test
That’s it! Try adding some data to your main database first, and then run your tests again. The data you’ve added to your main database should still be intact because PHPUnit is now using the test database instead. You can run the following query on your main database to test things out:
INSERT INTO `products` (`id`, `name`, `cost`, `image`, `created_at`, `updated_at`)
VALUES
(1,'pizza',10.00,'https://images.unsplash.com/photo-1593504049359-74330189a345?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=327&q=80','2022-01-23 16:14:20','2022-01-23 16:14:24'),
(2,'soup',1.30,'https://images.unsplash.com/photo-1603105037880-880cd4edfb0d?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=387&q=80','2022-01-29 13:24:39','2022-01-29 13:24:43'),
(3,'taco',4.20,'https://images.unsplash.com/photo-1565299585323-38d6b0865b47?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=480&q=80','2022-01-29 13:25:22','2022-01-29 13:25:22');
Alternatively, you can copy the database seeder from the tdd
branch to your project:
<?php
// database/seeders/ProductSeeder.php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use DB;
class ProductSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
DB::table('products')->insert([
'name' => 'pizza',
'cost' => '10.00',
'image' =>
'https://images.unsplash.com/photo-1593504049359-74330189a345?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=327&q=80',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('products')->insert([
'name' => 'soup',
'cost' => '1.30',
'image' =>
'https://images.unsplash.com/photo-1603105037880-880cd4edfb0d?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=387&q=80',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('products')->insert([
'name' => 'taco',
'cost' => '4.20',
'image' =>
'https://images.unsplash.com/photo-1565299585323-38d6b0865b47?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=480&q=80',
'created_at' => now(),
'updated_at' => now(),
]);
}
}
Be sure to call the ProductSeeder
in your database seeder:
<?php
// database/seeders/DatabaseSeeder.php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Database\Seeders\ProductSeeder;
class DatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*
* @return void
*/
public function run()
{
$this->call([
ProductSeeder::class,
]);
}
}
Once that's done, run php artisan db:seed
to seed the database with the default data.
Cart Test
The next step is to test and implement the cart functionality.
Start by generating a new test file:
php artisan make:test CartTest
First, test whether items can be added to the cart. To start writing this, you’ll need to assume that the endpoint already exists. Make a request to that endpoint, and then check whether the session was updated to include the item you passed to the request:
<?php
// tests/Feature/CartTest.php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use App\Models\Product;
class CartTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function item_can_be_added_to_the_cart()
{
Product::factory()->count(3)->create();
$this->post('/cart', [
'id' => 1,
])
->assertRedirect('/cart')
->assertSessionHasNoErrors()
->assertSessionHas('cart.0', [
'id' => 1,
'qty' => 1,
]);
}
In the above code, we submitted a POST
request to the /cart
endpoint. The array that we passed as a second argument emulates what would have been the form data in the browser. This is accessible via the usual means in the controller, so it will be the same as if you’ve submitted an actual form request. We then used three new assertions:
-
assertRedirect
- Asserts that the server redirects to a specific endpoint once the form is submitted. -
assertSessionHasErrors
- Asserts that the server didn’t return any errors via a flash session. This is typically used to verify that there are no form validation errors. -
assertSessionHas
- Asserts that the session has particular data in it. If it’s an array, you can use the index to refer to the specific index you want to check.
Running the test will then lead you to creating a route and then a controller that adds the item into the cart:
// routes/web.php
use App\Http\Controllers\CartController;
Route::post('/cart', [CartController::class, 'store']);
Generate the controller:
php artisan make:controller CartController
Then, add the code that pushes an item to the cart:
<?php
// app/Http/Controllers/CartController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Product;
class CartController extends Controller
{
public function store()
{
session()->push('cart', [
'id' => request('id'),
'qty' => 1, // default qty
]);
return redirect('/cart');
}
}
These steps should make the test pass. However, the problem is that we haven’t updated the search view yet to let the user add an item to the cart. As mentioned earlier, we can’t test for this. For now, let’s just update the view to include the form for adding an item to the cart:
<!-- resources/views/search.blade.php -->
<div class="mt-3">
@foreach ($items as $item)
<div class="card mb-3 overflow-hidden" style="max-width: 540px; max-height: 145px;">
<div class="row g-0">
<div class="col-md-4">
<img src="{{ $item->image }}" class="img-fluid rounded-start" alt="...">
</div>
<div class="col-md-8">
<div class="card-body">
<h5 class="card-title m-0 p-0">{{ $item->name }}</h5>
<span>${{ $item->cost }}</span>
<!-- UPDATE THIS SECTION -->
<div class="mt-2">
<form action="/cart" method="POST">
@csrf
<input type="hidden" name="id" value="{{ $item->id }}">
<button type="submit" class="btn btn-primary">Add</button>
</form>
</div>
<!-- END OF UPDATE -->
</div>
</div>
</div>
</div>
@endforeach
</div>
Simply pushing new items to the cart wouldn’t suffice, as a user might add the same item again, which we don’t want to happen. Instead, we want the user to increase the quantity of the item they previously added.
Here’s the test for this:
// tests/Feature/CartTest.php
/** @test */
public function same_item_cannot_be_added_to_the_cart_twice()
{
Product::factory()->create([
'name' => 'Taco',
'cost' => 1.5,
]);
Product::factory()->create([
'name' => 'Pizza',
'cost' => 2.1,
]);
Product::factory()->create([
'name' => 'BBQ',
'cost' => 3.2,
]);
$this->post('/cart', [
'id' => 1, // Taco
]);
$this->post('/cart', [
'id' => 1, // Taco
]);
$this->post('/cart', [
'id' => 2, // Pizza
]);
$this->assertEquals(2, count(session('cart')));
}
Obviously, it would fail since we’re not checking for duplicate items. Update the store()
method to include the code for checking for an existing item ID:
// app/Http/Controllers/CartController.php
public function store()
{
$existing = collect(session('cart'))->first(function ($row, $key) {
return $row['id'] == request('id');
});
if (!$existing) {
session()->push('cart', [
'id' => request('id'),
'qty' => 1,
]);
}
return redirect('/cart');
}
Next, test to see if the correct view is being used to present the cart page:
// tests/Feature/CartTest.php
/** @test */
public function cart_page_can_be_accessed()
{
Product::factory()->count(3)->create();
$this->get('/cart')
->assertViewIs('cart');
}
The above test would pass without you having to do anything since we still have the existing route from the starter code.
Next, we want to verify that items added to the cart can be seen from the cart page. Below, we’re using a new assertion called assertSeeTextInOrder()
. This accepts an array of strings that you are expecting to see on the page, in the correct order. In this case, we added a Taco and then BBQ, so we’ll check for this specific order:
// tests/Feature/CartTest.php
/** @test */
public function items_added_to_the_cart_can_be_seen_in_the_cart_page()
{
Product::factory()->create([
'name' => 'Taco',
'cost' => 1.5,
]);
Product::factory()->create([
'name' => 'Pizza',
'cost' => 2.1,
]);
Product::factory()->create([
'name' => 'BBQ',
'cost' => 3.2,
]);
$this->post('/cart', [
'id' => 1, // Taco
]);
$this->post('/cart', [
'id' => 3, // BBQ
]);
$cart_items = [
[
'id' => 1,
'qty' => 1,
'name' => 'Taco',
'image' => 'some-image.jpg',
'cost' => 1.5,
],
[
'id' => 3,
'qty' => 1,
'name' => 'BBQ',
'image' => 'some-image.jpg',
'cost' => 3.2,
],
];
$this->get('/cart')
->assertViewHas('cart_items', $cart_items)
->assertSeeTextInOrder([
'Taco',
'BBQ',
])
->assertDontSeeText('Pizza');
}
You might be wondering why we didn’t check for the other product data, such as the cost or quantity. You certainly can, but in this case, just seeing the product name is good enough. We’ll employ another test later on that checks for this.
Add the code for returning the cart page to the controller:
// app/Http/Controllers/CartController.php
public function index()
{
$items = Product::whereIn('id', collect(session('cart'))->pluck('id'))->get();
$cart_items = collect(session('cart'))->map(function ($row, $index) use ($items) {
return [
'id' => $row['id'],
'qty' => $row['qty'],
'name' => $items[$index]->name,
'image' => $items[$index]->image,
'cost' => $items[$index]->cost,
];
})->toArray();
return view('cart', compact('cart_items'));
}
Update the routes file accordingly:
// routes/web.php
Route::get('/cart', [CartController::class, 'index']); // replace existing route from the starter code
Then, update the view file so that it shows the cart items:
<!-- resources/views/cart.blade.php -->
<div class="mt-3">
@foreach ($cart_items as $item)
<div class="card mb-3 overflow-hidden" style="max-width: 540px; max-height: 145px;">
<div class="row g-0">
<div class="col-md-4">
<img src="{{ $item['image'] }}" class="img-fluid rounded-start" alt="...">
</div>
<div class="col-md-8">
<div class="card-body">
<div class="float-start">
<h5 class="card-title m-0 p-0">{{ $item['name'] }}</h5>
<span>${{ $item['cost'] }}</span>
</div>
<div class="float-end">
<button type="button" class="btn btn-link">Remove</button>
</div>
<div class="clearfix"></div>
<div class="mt-4">
<div class="col-auto">
<button type="button" class="btn btn-secondary btn-sm">-</button>
</div>
<div class="col-auto">
<input class="form-control form-control-sm" type="text" name="qty" value="{{ $item['qty'] }}" style="width: 50px;">
</div>
<div class="col-auto">
<button type="button" class="btn btn-secondary btn-sm">+</button>
</div>
</div>
</div>
</div>
</div>
</div>
@endforeach
</div>
This should make the test pass.
Next, we test whether a cart item can be removed from the cart. This is the additional test I mentioned earlier, which will verify that the corresponding cost and quantity can be seen from the page. In the code below, we’re taking a shortcut when it comes to adding items to the cart. Instead of making separate requests for adding each item, we’re directly constructing the cart
session instead. This is a perfectly valid approach, especially if your other tests already verify that adding items to the cart is working. It’s best to do it this way so that each test only focuses on what it needs to test:
// tests/Feature/CartTest.php
/** @test */
public function item_can_be_removed_from_the_cart()
{
Product::factory()->create([
'name' => 'Taco',
'cost' => 1.5,
]);
Product::factory()->create([
'name' => 'Pizza',
'cost' => 2.1,
]);
Product::factory()->create([
'name' => 'BBQ',
'cost' => 3.2,
]);
// add items to session
session(['cart' => [
['id' => 2, 'qty' => 1], // Pizza
['id' => 3, 'qty' => 3], // Taco
]]);
$this->delete('/cart/2') // remove Pizza
->assertRedirect('/cart')
->assertSessionHasNoErrors()
->assertSessionHas('cart', [
['id' => 3, 'qty' => 3]
]);
// verify that cart page is showing the expected items
$this->get('/cart')
->assertSeeInOrder([
'BBQ', // item name
'$3.2', // cost
'3', // qty
])
->assertDontSeeText('Pizza');
}
The above test would fail because we don’t have the endpoint in place yet. Go ahead and update it:
// routes/web.php
Route::delete('/cart/{id}', [CartController::class, 'destroy']);
Then, update the controller:
// app/Http/Controllers/CartController.php
public function destroy()
{
$id = request('id');
$items = collect(session('cart'))->filter(function ($item) use ($id) {
return $item['id'] != $id;
})->values()->toArray();
session(['cart' => $items]);
return redirect('/cart');
}
The test would succeed at this point, although we haven’t updated the view so that it accepts submissions of this particular form request. This is the same issue we had earlier when checking the functionality for adding items to the cart. Therefore, we won’t be tackling how to deal with this issue. For now, just update the cart view to include a form that submits to the endpoint responsible for removing items from the cart:
<!-- resources/views/cart.blade.php -->
@if ($cart_items && count($cart_items) > 0)
@foreach ($cart_items as $item)
<div class="card mb-3 overflow-hidden" style="max-width: 540px; max-height: 145px;">
<div class="row g-0">
<div class="col-md-4">
<img src="{{ $item['image'] }}" class="img-fluid rounded-start" alt="...">
</div>
<div class="col-md-8">
<div class="card-body">
<div class="float-start">
<h5 class="card-title m-0 p-0">{{ $item['name'] }}</h5>
<span>${{ $item['cost'] }}</span>
</div>
<!-- UPDATE THIS SECTION -->
<div class="float-end">
<form action="/cart/{{ $item['id'] }}" method="POST">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-sm btn-link">Remove</button>
</form>
</div>
<!-- END OF UPDATE -->
<div class="clearfix"></div>
<div class="mt-1">
<div class="col-auto">
<button type="button" class="btn btn-outline-secondary decrement-qty btn-sm">-</button>
</div>
<div class="col-auto">
<input class="form-control form-control-sm qty" type="text" name="qty" value="{{ $item['qty'] }}" style="width: 100px;">
</div>
<div class="col-auto">
<button type="button" class="btn btn-outline-secondary increment-qty btn-sm">+</button>
</div>
<div class="mt-2 d-grid">
<button type="submit" class="btn btn-secondary btn-sm">Update</button>
</div>
</div>
</div>
</div>
</div>
</div>
@endforeach
<div class="d-grid gap-2">
<button class="btn btn-primary" type="button">Checkout</button>
</div>
@else
<div>Cart is empty.</div>
@endif
Next, add a test for checking whether the cart item’s quantity can be updated:
// tests/Feature/CartTest.php
/** @test */
public function cart_item_qty_can_be_updated()
{
Product::factory()->create([
'name' => 'Taco',
'cost' => 1.5,
]);
Product::factory()->create([
'name' => 'Pizza',
'cost' => 2.1,
]);
Product::factory()->create([
'name' => 'BBQ',
'cost' => 3.2,
]);
// add items to session
session(['cart' => [
['id' => 1, 'qty' => 1], // Taco
['id' => 3, 'qty' => 1], // BBQ
]]);
$this->patch('/cart/3', [ // update qty of BBQ to 5
'qty' => 5,
])
->assertRedirect('/cart')
->assertSessionHasNoErrors()
->assertSessionHas('cart', [
['id' => 1, 'qty' => 1],
['id' => 3, 'qty' => 5],
]);
// verify that cart page is showing the expected items
$this->get('/cart')
->assertSeeInOrder([
// Item #1
'Taco',
'$1.5',
'1',
// Item #2
'BBQ',
'$3.2',
'5',
]);
}
To make the test pass, begin by updating the routes:
// routes/web.php
Route::patch('/cart/{id}', [CartController::class, 'update']);
Then, update the controller so that it finds the item passed in the request and updates their quantity:
// app/Http/Controllers/CartController.php
public function update()
{
$id = request('id');
$qty = request('qty');
$items = collect(session('cart'))->map(function ($row) use ($id, $qty) {
if ($row['id'] == $id) {
return ['id' => $row['id'], 'qty' => $qty];
}
return $row;
})->toArray();
session(['cart' => $items]);
return redirect('/cart');
}
These steps should make the test pass, but we still have the problem of the view not allowing the user to submit this specific request. Thus, we need to update it again:
<!-- resources/views/cart.blade.php -->
@if ($cart_items && count($cart_items) > 0)
@foreach ($cart_items as $item)
<div class="card mb-3 overflow-hidden" style="max-width: 540px; max-height: 145px;">
<div class="row g-0">
<div class="col-md-4">
<img src="{{ $item['image'] }}" class="img-fluid rounded-start" alt="...">
</div>
<div class="col-md-8">
<div class="card-body">
<div class="float-start">
<h5 class="card-title m-0 p-0">{{ $item['name'] }}</h5>
<span>${{ $item['cost'] }}</span>
</div>
<div class="float-end">
<form action="/cart/{{ $item['id'] }}" method="POST">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-sm btn-link">Remove</button>
</form>
</div>
<div class="clearfix"></div>
<!-- UPDATE THIS SECTION -->
<div class="mt-1">
<form method="POST" action="/cart/{{ $item['id'] }}" class="row">
@csrf
@method('PATCH')
<div class="col-auto">
<button type="button" class="btn btn-outline-secondary decrement-qty btn-sm">-</button>
</div>
<div class="col-auto">
<input class="form-control form-control-sm qty" type="text" name="qty" value="{{ $item['qty'] }}" style="width: 100px;">
</div>
<div class="col-auto">
<button type="button" class="btn btn-outline-secondary increment-qty btn-sm">+</button>
</div>
<div class="mt-2 d-grid">
<button type="submit" class="btn btn-secondary btn-sm">Update</button>
</div>
</form>
</div>
<!-- END OF UPDATE -->
</div>
</div>
</div>
</div>
@endforeach
<div class="d-grid gap-2">
<button class="btn btn-primary" type="button">Checkout</button>
</div>
@else
<div>Cart is empty.</div>
@endif
Checkout Test
Let’s proceed to the checkout test, where we verify that the checkout functionality is working. Generate the test file:
php artisan make:test CheckoutTest
First, we need to check whether the items added to the cart can be seen on the checkout page. There’s nothing new here; all we’re doing is verifying that the view has the expected data and that it shows them on the page:
<?php
// tests/Feature/CheckoutTest.php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use App\Models\Product;
class CheckoutTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function cart_items_can_be_seen_from_the_checkout_page()
{
Product::factory()->create([
'name' => 'Taco',
'cost' => 1.5,
]);
Product::factory()->create([
'name' => 'Pizza',
'cost' => 2.1,
]);
Product::factory()->create([
'name' => 'BBQ',
'cost' => 3.2,
]);
session([
'cart' => [
['id' => 2, 'qty' => 1], // Pizza
['id' => 3, 'qty' => 2], // BBQ
],
]);
$checkout_items = [
[
'id' => 2,
'qty' => 1,
'name' => 'Pizza',
'cost' => 2.1,
'subtotal' => 2.1,
'image' => 'some-image.jpg',
],
[
'id' => 3,
'qty' => 2,
'name' => 'BBQ',
'cost' => 3.2,
'subtotal' => 6.4,
'image' => 'some-image.jpg',
],
];
$this->get('/checkout')
->assertViewIs('checkout')
->assertViewHas('checkout_items', $checkout_items)
->assertSeeTextInOrder([
// Item #1
'Pizza',
'$2.1',
'1x',
'$2.1',
// Item #2
'BBQ',
'$3.2',
'2x',
'$6.4',
'$8.5', // total
]);
}
}
This test will fail, so you’ll need to create the controller:
php artisan make:controller CheckoutController
Add the following code to the controller. This is very similar to what we have done with the cart controller’s index
method. The only difference is that we now have a subtotal
for each item and then sum them all up in the total
variable:
<?php
// app/Http/Controllers/CheckoutController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Product;
class CheckoutController extends Controller
{
public function index()
{
$items = Product::whereIn(
'id',
collect(session('cart'))->pluck('id')
)->get();
$checkout_items = collect(session('cart'))->map(function (
$row,
$index
) use ($items) {
$qty = (int) $row['qty'];
$cost = (float) $items[$index]->cost;
$subtotal = $cost * $qty;
return [
'id' => $row['id'],
'qty' => $qty,
'name' => $items[$index]->name,
'cost' => $cost,
'subtotal' => round($subtotal, 2),
];
});
$total = $checkout_items->sum('subtotal');
$checkout_items = $checkout_items->toArray();
return view('checkout', compact('checkout_items', 'total'));
}
}
Don’t forget to update the routes file:
// routes/web.php
use App\Http\Controllers\CheckoutController;
Route::get('/checkout', [CheckoutController::class, 'index']); // replace existing code from starter
Additionally, update the view file:
<!-- resources/views/checkout.blade.php -->
<h6>Order Summary</h6>
<table class="table table-borderless">
<thead>
<tr>
<th>Item</th>
<th>Price</th>
<th>Qty</th>
<th>Subtotal</th>
</tr>
</thead>
<tbody>
@foreach ($checkout_items as $item)
<tr>
<td>{{ $item['name'] }}</td>
<td>${{ $item['cost'] }}</td>
<td>{{ $item['qty'] }}x</td>
<td>${{ $item['subtotal'] }}</td>
</tr>
@endforeach
</tbody>
</table>
<div>
Total: ${{ $total }}
</div>
The last thing that we’re going to test is the creation of orders. As mentioned earlier, we won’t be processing payments in this project. Instead, we’ll only create the order in the database. This time, our arrange phase involves hitting up the endpoints for adding, updating, and deleting items from the cart. This goes against what I mentioned earlier, that you should only be doing the setup specific to the thing you’re testing. This is what we did for the item_can_be_removed_from_the_cart
and cart_item_qty_can_be_updated
tests earlier. Instead of making a separate request for adding items, we directly updated the session instead.
There’s always an exception to every rule. In this case, we need to hit up the endpoints instead of directly manipulating the session so that we can test whether the whole checkout flow is working as expected. Once a request has been made to the /checkout
endpoint, we expect the database to contain specific records. To verify this, we use assertDatabaseHas()
, which accepts the name of the table as its first argument and the column-value pair you’re expecting to see. Note that this only accepts a single row, so you’ll have to call it multiple times if you want to verify multiple rows:
// tests/Feature/CheckoutTest.php
/** @test */
public function order_can_be_created()
{
Product::factory()->create([
'name' => 'Taco',
'cost' => 1.5,
]);
Product::factory()->create([
'name' => 'Pizza',
'cost' => 2.1,
]);
Product::factory()->create([
'name' => 'BBQ',
'cost' => 3.2,
]);
// add items to cart
$this->post('/cart', [
'id' => 1, // Taco
]);
$this->post('/cart', [
'id' => 2, // Pizza
]);
$this->post('/cart', [
'id' => 3, // BBQ
]);
// update qty of taco to 5
$this->patch('/cart/1', [
'qty' => 5,
]);
// remove pizza
$this->delete('/cart/2');
$this->post('/checkout')
->assertSessionHasNoErrors()
->assertRedirect('/summary');
// check that the order has been added to the database
$this->assertDatabaseHas('orders', [
'total' => 10.7,
]);
$this->assertDatabaseHas('order_details', [
'order_id' => 1,
'product_id' => 1,
'cost' => 1.5,
'qty' => 5,
]);
$this->assertDatabaseHas('order_details', [
'order_id' => 1,
'product_id' => 3,
'cost' => 3.2,
'qty' => 1,
]);
}
To make the test pass, add the create
method to the checkout controller. Here, we’re basically doing the same thing we did in the index
method earlier. This time, however, we’re saving the total and the order details to their corresponding tables:
<?php
// app/Http/Controllers/CheckoutController.php
// ...
use App\Models\Order;
class CheckoutController extends Controller
{
// ...
public function create()
{
$items = Product::whereIn(
'id',
collect(session('cart'))->pluck('id')
)->get();
$checkout_items = collect(session('cart'))->map(function (
$row,
$index
) use ($items) {
$qty = (int) $row['qty'];
$cost = (float) $items[$index]->cost;
$subtotal = $cost * $qty;
return [
'id' => $row['id'],
'qty' => $qty,
'name' => $items[$index]->name,
'cost' => $cost,
'subtotal' => round($subtotal, 2),
];
});
$total = $checkout_items->sum('subtotal');
$order = Order::create([
'total' => $total,
]);
foreach ($checkout_items as $item) {
$order->detail()->create([
'product_id' => $item['id'],
'cost' => $item['cost'],
'qty' => $item['qty'],
]);
}
return redirect('/summary');
}
}
For the above code to work, we need to generate a migration file for creating the orders
and order_details
table:
php artisan make:migration create_orders_table
php artisan make:migration create_order_details_table
Here are the contents for the create orders table migration file:
<?php
// database/migrations/{datetime}_create_orders_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateOrdersTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->float('total');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('orders');
}
}
And here are the contents for the create order details table migration file:
<?php
// database/migrations/{datetime}_create_order_details_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateOrderDetailsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('order_details', function (Blueprint $table) {
$table->id();
$table->bigInteger('order_id');
$table->bigInteger('product_id');
$table->float('cost');
$table->integer('qty');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('order_details');
}
}
Run the migration:
php artisan migrate
Next, we need to generate models for the two tables:
php artisan make:model Order
php artisan make:model OrderDetail
Here’s the code for the Order model:
<?php
// app/Models/Order.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Models\OrderDetail;
class Order extends Model
{
use HasFactory;
protected $guarded = [];
public function detail()
{
return $this->hasMany(OrderDetail::class, 'order_id');
}
}
And here’s the code for the Order Detail model:
<?php
// app/Models/OrderDetail.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Models\Order;
class OrderDetail extends Model
{
use HasFactory;
protected $guarded = [];
public $timestamps = false;
}
Additionally, update the routes file:
// routes/web.php
Route::post('/checkout', [CheckoutController::class, 'create']);
Refactoring the Code
We’ll wrap up implementing functionality here. There’s still another page left (the summary page), but the code for that is pretty much the same as the checkout page, so I’ll leave it for you to implement as an exercise. What we’ll do instead is refactor the code because, as you’ve probably noticed, there’s a lot of repetition going on, especially on the test files. Repetition isn’t necessarily bad, especially on test files, because it usually makes it easier for the reader to grasp what’s going on at a glance. This is the opposite of hiding the logic within methods just so you can save a few lines of code.
Therefore, in this section, we’ll focus on refactoring the project code. This is where having some test codes really shines because you can just run the tests after you’ve refactored the code. This allows you to easily check whether you’ve broken something. This applies not just to refactoring but also updating existing features. You’ll know immediately that you’ve messed something up without having to go to the browser and test things manually.
Issue with RefreshDatabase
Before proceeding, make sure that all tests are passing:
vendor/bin/phpunit
You should see the following output:
............ 12 / 12 (100%)
Time: 00:00.442, Memory: 30.00 MB
OK (12 tests, 37 assertions)
If not, then you'll most likely seeing some gibberish output which looks like this:
1) Tests\Feature\CartTest::items_added_to_the_cart_can_be_seen_in_the_cart_page
The response is not a view.
/Users/wernancheta/projects/food-order-app-laravel-tdd/vendor/laravel/framework/src/Illuminate/Testing/TestResponse.php:1068
/Users/wernancheta/projects/food-order-app-laravel-tdd/vendor/laravel/framework/src/Illuminate/Testing/TestResponse.php:998
/Users/wernancheta/projects/food-order-app-laravel-tdd/tests/Feature/CartTest.php:86
phpvfscomposer:///Users/wernancheta/projects/food-order-app-laravel-tdd/vendor/phpunit/phpunit/phpunit:97
2) Tests\Feature\CartTest::item_can_be_removed_from_the_cart
Failed asserting that '\n
\n
\n
\n
\n
\n
pre.sf-dump {\n
display: none !important;\n
}\n
\n
\n
\n
\n
\n
\n
\n
🧨 Undefined offset: 0\n
\n
\n
\n
\n
\n
\n
window.data = {"report":{"notifier":"Laravel Client","language":"PHP","framework_version":"8.81.0","language_version":"7.4.27","exception_class":"ErrorException","seen_at":1643885307,"message":"Undefined offset: 0","glows":[],"solutions":[],"stacktrace":[{"line_number":1641,"method":"handleError","class":"Illuminate\\Foundation\\Bootstrap\\HandleExceptions","code_snippet":{"1626":" #[\\ReturnTypeWillChange]","1627":" public function offsetExists($key)","1628":" {","1629":" return isset($this-\u003Eitems[$key]);","1630":" }","1631":"","1632":" \/**","1633":" * Get an item at a given offset.","1634":" *","1635":" * @param mixed $key","1636":" * @return mixed","1637":" *\/","1638":" #[\\ReturnTypeWillChange]","1639":" public function offsetGet($key)","1640":" {","1641":" return $this-\u003Eitems[$key];","1642":" }","1643":"","1644":" \/**","1645":" * Set the item at a given offset.","1646":" *","1647":" * @param mixed $key","1648":" * @param
That’s not the full error but you get the idea. The issue is that RefreshDatabase
didn't work as expected and some data lingered between each test which caused the other tests to fail. The solution for that is to have PHPUnit automatically truncate all the tables after each test is run. You can do that by updating the tearDown()
method in the tests/TestCase.php
file:
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use DB;
abstract class TestCase extends BaseTestCase
{
use CreatesApplication;
public function tearDown(): void
{
$sql = "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = 'food_order_test';"; // replace food_order_test with the name of your test database
DB::statement("SET FOREIGN_KEY_CHECKS = 0;");
$tables = DB::select($sql);
array_walk($tables, function($table){
if ($table->TABLE_NAME != 'migrations') {
DB::table($table->TABLE_NAME)->truncate();
}
});
DB::statement("SET FOREIGN_KEY_CHECKS = 1;");
parent::tearDown();
}
}
Once that's done, all the tests should now pass.
Refactor the Product Search Code
Moving on, first, let’s refactor the code for the search controller. It currently looks like this:
<?php
// app/Http/Controller/SearchController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Product;
class SearchProductsController extends Controller
{
public function index()
{
$query_str = request('query');
$items = Product::when($query_str, function ($query, $query_str) {
return $query->where('name', 'LIKE', "%{$query_str}%");
})->get();
return view('search', compact('items'));
}
}
It would be nice if we could encapsulate the query logic within the Eloquent model itself so that we could do something like this. This way, we can reuse the same query somewhere else:
<?php
// app/Http/Controller/SearchController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Product;
class SearchProductsController extends Controller
{
public function index()
{
$query_str = request('query');
$items = Product::matches($query_str)->get(); // update this
return view('search', compact('items'));
}
}
We can do this by adding a matches
method to the model:
<?php
// app/Models/Product.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
use HasFactory;
protected $guarded = [];
public static function matches($query_str)
{
return self::when($query_str, function ($query, $query_str) {
return $query->where('name', 'LIKE', "%{$query_str}%");
});
}
}
Refactor the Cart Code
Next, we have the cart controller. If you just scroll through the file, you’ll notice that we’re manipulating or getting data from the cart
session a lot:
<?php
// app/Http/Controllers/CartController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Product;
class CartController extends Controller
{
public function index()
{
$items = Product::whereIn(
'id',
collect(session('cart'))->pluck('id')
)->get();
$cart_items = collect(session('cart'))
->map(function ($row, $index) use ($items) {
return [
'id' => $row['id'],
'qty' => $row['qty'],
'name' => $items[$index]->name,
'cost' => $items[$index]->cost,
];
})
->toArray();
return view('cart', compact('cart_items'));
}
public function store()
{
$existing = collect(session('cart'))->first(function ($row, $key) {
return $row['id'] == request('id');
});
if (!$existing) {
session()->push('cart', [
'id' => request('id'),
'qty' => 1,
]);
}
return redirect('/cart');
}
public function destroy()
{
$id = request('id');
$items = collect(session('cart'))
->filter(function ($item) use ($id) {
return $item['id'] != $id;
})
->values()
->toArray();
session(['cart' => $items]);
return redirect('/cart');
}
public function update()
{
$id = request('id');
$qty = request('qty');
$items = collect(session('cart'))
->map(function ($row) use ($id, $qty) {
if ($row['id'] == $id) {
return ['id' => $row['id'], 'qty' => $qty];
}
return $row;
})
->toArray();
session(['cart' => $items]);
return redirect('/cart');
}
}
It would be nice if we could encapsulate all this logic within a service class. This way, we could reuse the same logic within the checkout controller:
<?php
// app/Http/Controllers/CartController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Product;
use App\Services\CartService;
class CartController extends Controller
{
public function index(CartService $cart)
{
$cart_items = $cart->get();
return view('cart', compact('cart_items'));
}
public function store(CartService $cart)
{
$cart->add(request('id'));
return redirect('/cart');
}
public function destroy(CartService $cart)
{
$id = request('id');
$cart->remove($id);
return redirect('/cart');
}
public function update(CartService $cart)
{
$cart->update(request('id'), request('qty'));
return redirect('/cart');
}
}
Here’s the code for the cart service. Create a Services
folder inside the app
directory, and then create a CartService.php
file:
<?php
// app/Services/CartService.php
namespace App\Services;
use App\Models\Product;
class CartService
{
private $cart;
private $items;
public function __construct()
{
$this->cart = collect(session('cart'));
$this->items = Product::whereIn('id', $this->cart->pluck('id'))->get();
}
public function get()
{
return $this->cart
->map(function ($row, $index) {
return [
'id' => $row['id'],
'qty' => $row['qty'],
'name' => $this->items[$index]->name,
'image' => $this->items[$index]->image,
'cost' => $this->items[$index]->cost,
];
})
->toArray();
}
private function exists($id)
{
return $this->cart->first(function ($row, $key) use ($id) {
return $row['id'] == $id;
});
}
public function add($id)
{
$existing = $this->exists($id);
if (!$existing) {
session()->push('cart', [
'id' => $id,
'qty' => 1,
]);
return true;
}
return false;
}
public function remove($id)
{
$items = $this->cart
->filter(function ($item) use ($id) {
return $item['id'] != $id;
})
->values()
->toArray();
session(['cart' => $items]);
}
public function update($id, $qty)
{
$items = $this->cart
->map(function ($row) use ($id, $qty) {
if ($row['id'] == $id) {
return ['id' => $row['id'], 'qty' => $qty];
}
return $row;
})
->toArray();
session(['cart' => $items]);
}
}
Refactor the Checkout Code
Finally, we have the checkout controller, which could use a little help from the cart service we’ve just created:
<?php
// app/Http/Controllers/CheckoutController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Product;
use App\Models\Order;
class CheckoutController extends Controller
{
public function index()
{
$items = Product::whereIn(
'id',
collect(session('cart'))->pluck('id')
)->get();
$checkout_items = collect(session('cart'))->map(function (
$row,
$index
) use ($items) {
$qty = (int) $row['qty'];
$cost = (float) $items[$index]->cost;
$subtotal = $cost * $qty;
return [
'id' => $row['id'],
'qty' => $qty,
'name' => $items[$index]->name,
'cost' => $cost,
'subtotal' => round($subtotal, 2),
];
});
$total = $checkout_items->sum('subtotal');
$checkout_items = $checkout_items->toArray();
return view('checkout', compact('checkout_items', 'total'));
}
public function create()
{
$items = Product::whereIn(
'id',
collect(session('cart'))->pluck('id')
)->get();
$checkout_items = collect(session('cart'))->map(function (
$row,
$index
) use ($items) {
$qty = (int) $row['qty'];
$cost = (float) $items[$index]->cost;
$subtotal = $cost * $qty;
return [
'id' => $row['id'],
'qty' => $qty,
'name' => $items[$index]->name,
'cost' => $cost,
'subtotal' => round($subtotal, 2),
];
});
$total = $checkout_items->sum('subtotal');
$order = Order::create([
'total' => $total,
]);
foreach ($checkout_items as $item) {
$order->detail()->create([
'product_id' => $item['id'],
'cost' => $item['cost'],
'qty' => $item['qty'],
]);
}
return redirect('/summary');
}
}
Let’s proceed with refactoring. To do this, we can update the get
method in the cart service to include a subtotal
:
// app/Services/CartService.php
public function get()
{
return $this->cart->map(function ($row, $index) {
$qty = (int) $row['qty'];
$cost = (float) $this->items[$index]->cost;
$subtotal = $cost * $qty;
return [
'id' => $row['id'],
'qty' => $qty,
'name' => $this->items[$index]->name,
'image' => $this->items[$index]->image,
'cost' => $cost,
'subtotal' => round($subtotal, 2),
];
})->toArray();
}
We also need to add a total
method to get the cart total:
// app/Services/CartService.php
public function total()
{
$items = collect($this->get());
return $items->sum('subtotal');
}
You can then update the checkout controller to make use of the cart service:
<?php
// app/Http/Controllers/CheckoutController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Product;
use App\Models\Order;
use App\Services\CartService;
class CheckoutController extends Controller
{
public function index(CartService $cart)
{
$checkout_items = $cart->get();
$total = $cart->total();
return view('checkout', compact('checkout_items', 'total'));
}
public function create(CartService $cart)
{
$checkout_items = $cart->get();
$total = $cart->total();
$order = Order::create([
'total' => $total,
]);
foreach ($checkout_items as $item) {
$order->detail()->create([
'product_id' => $item['id'],
'cost' => $item['cost'],
'qty' => $item['qty'],
]);
}
return redirect('/summary');
}
}
Note that if you run the whole test suite at this point, you’ll get an error on items_added_to_the_cart_can_be_seen_in_the_cart_page
because our expected view data changed after adding a subtotal
field. To make the test pass, you’ll need to add this field with the expected value:
// tests/Feature/CartTest.php
/** @test */
public function items_added_to_the_cart_can_be_seen_in_the_cart_page()
{
Product::factory()->create([
'name' => 'Taco',
'cost' => 1.5,
]);
Product::factory()->create([
'name' => 'Pizza',
'cost' => 2.1,
]);
Product::factory()->create([
'name' => 'BBQ',
'cost' => 3.2,
]);
$this->post('/cart', [
'id' => 1, // Taco
]);
$this->post('/cart', [
'id' => 3, // BBQ
]);
$cart_items = [
[
'id' => 1,
'qty' => 1,
'name' => 'Taco',
'image' => 'some-image.jpg',
'cost' => 1.5,
'subtotal' => 1.5, // add this
],
[
'id' => 3,
'qty' => 1,
'name' => 'BBQ',
'image' => 'some-image.jpg',
'cost' => 3.2,
'subtotal' => 3.2, // add this
],
];
$this->get('/cart')
->assertViewHas('cart_items', $cart_items)
->assertSeeTextInOrder([
'Taco',
'BBQ',
])
->assertDontSeeText('Pizza');
}
Conclusion and Next Steps
That’s it! In this tutorial, you’ve learned the basics of test-driven development in Laravel by building a real-world app. Specifically, you learned the TDD workflow, the 3-phase pattern used by each test, and a few assertions you can use to verify that a specific functionality works the way it should. By now, you should have the basic tools required to start building future projects using TDD.
There’s still a lot to learn if you want to be able to write your projects using TDD. A big part of this is mocking, which is where you swap out a fake implementation of specific functionality on your tests so that it’s easier to run. Laravel already includes fakes for common functionality provided by the framework. This includes storage fake, queue fake, and bus fake, among others. You can read the official documentation to learn more about it. You can also view the source code of the app on its GitHub repo.
Top comments (0)