Building a Laravel App with TDD

honeybadger_staff

Honeybadger Staff

Posted on October 20, 2022

Building a Laravel App with TDD

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
Enter fullscreen mode Exit fullscreen mode

Next, install the composer dependencies:

composer install
Enter fullscreen mode Exit fullscreen mode

Rename the .env.example file to .env and generate a new key:

php artisan key:generate
Enter fullscreen mode Exit fullscreen mode

Next, install the frontend dependencies:

npm install
Enter fullscreen mode Exit fullscreen mode

Once that’s done, you should be able to run the project:

php artisan serve
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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:

Search 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:

Cart page

Next, we have the checkout page where the user can submit the order. This is accessible via the /checkout route:

Checkout page

Lastly, we have the order confirmation page. This is accessible via the /summary route:

Summary page

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
Enter fullscreen mode Exit fullscreen mode

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 the Cart class adds an item to the user’s cart session.
  • Calling the remove() method in the Cart 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

Once that’s done, you should be able to do this instead:

p
pf Searchtest
pf test_example
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

Alternatively, you can prefix it with the word test_:

// tests/Feature/SearchTest.php

public function test_food_search_page_is_accessible()
{
    $this->get('/')
        ->assertOk();
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. Arrange
  2. Act
  3. 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
Enter fullscreen mode Exit fullscreen mode

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

}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Again, just do the minimum step required and run the test again:

php artisan make:factory ProductFactory
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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=
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The logical next step is to create a migration:

php artisan make:migration create_products_table
Enter fullscreen mode Exit fullscreen mode

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();
    });
}
Enter fullscreen mode Exit fullscreen mode

Once you’re done updating the migration file:

php artisan migrate
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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',
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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('/');

}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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".
Enter fullscreen mode Exit fullscreen mode

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'));
});
Enter fullscreen mode Exit fullscreen mode

At this point, you’ll now have your first passing test:

OK (1 test, 2 assertions)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'));
    }
}
Enter fullscreen mode Exit fullscreen mode

Don’t forget to update your routes file:

// routes/web.php

use App\Http\Controllers\SearchProductsController;

Route::get('/', [SearchProductsController::class, 'index']);
Enter fullscreen mode Exit fullscreen mode

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,
        ]);
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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']);
Enter fullscreen mode Exit fullscreen mode

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'));
}
Enter fullscreen mode Exit fullscreen mode

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)
    ...
Enter fullscreen mode Exit fullscreen mode

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..
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then, create a database specifically intended for testing:

CREATE DATABASE food_order_test;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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(),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
        ]);
    }
Enter fullscreen mode Exit fullscreen mode

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']);
Enter fullscreen mode Exit fullscreen mode

Generate the controller:

php artisan make:controller CartController
Enter fullscreen mode Exit fullscreen mode

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');
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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')));

}
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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');

}
Enter fullscreen mode Exit fullscreen mode

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');

}
Enter fullscreen mode Exit fullscreen mode

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'));
}
Enter fullscreen mode Exit fullscreen mode

Update the routes file accordingly:

// routes/web.php

Route::get('/cart', [CartController::class, 'index']); // replace existing route from the starter code
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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');

}
Enter fullscreen mode Exit fullscreen mode

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']);
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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',
        ]);

}
Enter fullscreen mode Exit fullscreen mode

To make the test pass, begin by updating the routes:

// routes/web.php

Route::patch('/cart/{id}', [CartController::class, 'update']);
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
            ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

This test will fail, so you’ll need to create the controller:

php artisan make:controller CheckoutController
Enter fullscreen mode Exit fullscreen mode

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'));
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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,
    ]);
}
Enter fullscreen mode Exit fullscreen mode

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');
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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');
    }
}
Enter fullscreen mode Exit fullscreen mode

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');
    }
}
Enter fullscreen mode Exit fullscreen mode

Run the migration:

php artisan migrate
Enter fullscreen mode Exit fullscreen mode

Next, we need to generate models for the two tables:

php artisan make:model Order
php artisan make:model OrderDetail
Enter fullscreen mode Exit fullscreen mode

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');
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Additionally, update the routes file:

// routes/web.php

Route::post('/checkout', [CheckoutController::class, 'create']);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

You should see the following output:

............                                                      12 / 12 (100%)

Time: 00:00.442, Memory: 30.00 MB

OK (12 tests, 37 assertions)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
    }

}
Enter fullscreen mode Exit fullscreen mode

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'));
    }
}
Enter fullscreen mode Exit fullscreen mode

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'));
    }
}
Enter fullscreen mode Exit fullscreen mode

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}%");
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

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');
    }
}
Enter fullscreen mode Exit fullscreen mode

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');
    }
}
Enter fullscreen mode Exit fullscreen mode

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]);
    }
}
Enter fullscreen mode Exit fullscreen mode

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');
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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');
    }
}
Enter fullscreen mode Exit fullscreen mode

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');

}
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
honeybadger_staff
Honeybadger Staff

Posted on October 20, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related