Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
October 20, 2022 07:27 pm GMT

Building a Laravel App with TDD

This article was originally written by
Wern Ancheta
on the Honeybadger Developer Blog.

In this tutorial, Ill 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.

Well 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 wont 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 Laravels 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, youll need to clone the GitHub repo and switch to the starter branch:

git clone [email protected]:anchetaWern/food-order-app-laravel-tdd.gitcd food-order-app-laravel-tddgit 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 thats 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 were going to build:

http://127.0.0.1:8000/

First, we have the search page where users can search for the food theyre 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 theyve 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, well 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 wont be covering any of the frontend aspects, such as HTML, JavaScript, or the CSS code. This is why its 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 doesnt 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, youre basically just interacting with it as if its 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.

Its going to feel a bit weird when youre just starting out, but youll get used to it after writing a few dozen tests and going through the whole cycle.

Creating a Test

Lets 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 the Cart class adds an item to the users cart session.
  • Calling the remove() method in the Cart class removes an item from the users cart session.

When you open the tests/Feature/SearchTest.php file, youll see the following:

<?php// tests/Feature/SearchTest.phpnamespace 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 apps 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. Theres 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 MBOK (3 tests, 3 assertions)

Delete the default ones in the tests/Feature and tests/Unit folders, as we wont 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 SearchTestvendor/bin/phpunit --filter test_example

This is the only command you need to remember to get started. Obviously, its hard to type out the whole thing over and over again, so add an alias instead. Execute these two commands while inside the projects root directory:

alias p='vendor/bin/phpunit'alias pf='vendor/bin/phpunit --filter'

Once thats done, you should be able to do this instead:

ppf Searchtestpf test_example

Search Test

Now were 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.phpnamespace Tests\Feature;use Tests\TestCase;// this is where we'll import the classes needed by the tests to runclass SearchTest extends TestCase{    // this is where we'll write some test code}

To start, lets 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.phppublic 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, well 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 youre testing. These are only test methods, so you can have a very long method name, as long as it clearly describes what youre testing.

If you have switched over to the starter branch of the repo, youll see that we already put the necessary code for the test to pass:

// routes/web.phpRoute::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:

  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 thats 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.phpuse 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_dataError: 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 whats 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_dataError: 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_dataIlluminate\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 byPDOException: SQLSTATE[HY000] [1049] Unknown database 'laravel'

It makes sense because we havent set up the database yet. Go ahead and update the projects .env file with the correct database credentials:

DB_CONNECTION=mysqlDB_HOST=127.0.0.1DB_PORT=3306DB_DATABASE=food_orderDB_USERNAME=rootDB_PASSWORD=

You also need to create the corresponding database using your database client. Once thats done, run the test again, and you should get the following error:

1) Tests\Feature\SearchTest::food_search_page_has_all_the_required_page_dataIlluminate\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 byPDOException: 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 wont 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.phppublic function up(){    Schema::create('products', function (Blueprint $table) {        $table->id();        $table->string('name');        $table->float('cost');        $table->string('image');        $table->timestamps();    });}

Once youre 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_dataIlluminate\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 byPDOException: 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 were 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.phpnamespace 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_dataThis test did not perform any assertionsOK, 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

Theres 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, youll see our first real issue that doesnt have anything to do with app setup:

1) Tests\Feature\SearchTest::food_search_page_has_all_the_required_page_datanull does not match expected type "object".

Again, to make the test pass, only invest the least amount of effort required:

// routes/web.phpRoute::get('/', function () {    $items = App\Models\Product::get();    return view('search', compact('items'));});

At this point, youll now have your first passing test:

OK (1 test, 2 assertions)

Refactor the code

The next step is to refactor your code. We dont 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.phpnamespace 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'));    }}

Dont forget to update your routes file:

// routes/web.phpuse App\Http\Controllers\SearchProductsController;Route::get('/', [SearchProductsController::class, 'index']);

Weve 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, Ill 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, Ill only be explaining the test code and the implementation without going through the whole workflow.

The previous test didnt 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. Whats the best way to present the code so that he or she will easily understand what youre trying to test? In this case, were trying to test whether a specific item would show up on the page, so its 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 isnt 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.phppublic 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'));}

Dont forget to update the view so that it has a form for accepting the users 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)    ...

Thats one of the weakness of this kind of test, because it doesnt make it easy to verify that a form exists in the page. Theres the assertSee method, but verifying with HTML isnt recommend since it might frequently be updated based on design or copy changes. For these types of tests, youre better off using Laravel Dusk instead. However, thats 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 dont 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.phpuse 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

Thats it! Try adding some data to your main database first, and then run your tests again. The data youve 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.phpnamespace 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.phpnamespace 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, youll 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.phpnamespace 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 youve 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 didnt 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 its 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.phpuse 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.phpnamespace 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 havent updated the search view yet to let the user add an item to the cart. As mentioned earlier, we cant test for this. For now, lets 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 wouldnt suffice, as a user might add the same item again, which we dont want to happen. Instead, we want the user to increase the quantity of the item they previously added.

Heres 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 were not checking for duplicate items. Update the store() method to include the code for checking for an existing item ID:

// app/Http/Controllers/CartController.phppublic 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, were 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 well 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 didnt 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. Well employ another test later on that checks for this.

Add the code for returning the cart page to the controller:

// app/Http/Controllers/CartController.phppublic 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.phpRoute::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, were taking a shortcut when it comes to adding items to the cart. Instead of making separate requests for adding each item, were 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. Its 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 dont have the endpoint in place yet. Go ahead and update it:

// routes/web.phpRoute::delete('/cart/{id}', [CartController::class, 'destroy']);

Then, update the controller:

// app/Http/Controllers/CartController.phppublic 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 havent 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 wont 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 items 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.phpRoute::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.phppublic 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

Lets 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. Theres nothing new here; all were doing is verifying that the view has the expected data and that it shows them on the page:

<?php// tests/Feature/CheckoutTest.phpnamespace 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 youll 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 controllers 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.phpnamespace 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'));    }}

Dont forget to update the routes file:

// routes/web.phpuse 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 were going to test is the creation of orders. As mentioned earlier, we wont be processing payments in this project. Instead, well 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 youre 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.

Theres 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 youre expecting to see. Note that this only accepts a single row, so youll 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, were basically doing the same thing we did in the index method earlier. This time, however, were 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_tablephp 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.phpuse 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.phpuse 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 Orderphp artisan make:model OrderDetail

Heres the code for the Order model:

<?php// app/Models/Order.phpnamespace 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 heres the code for the Order Detail model:

<?php// app/Models/OrderDetail.phpnamespace 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.phpRoute::post('/checkout', [CheckoutController::class, 'create']);

Refactoring the Code

Well wrap up implementing functionality here. Theres still another page left (the summary page), but the code for that is pretty much the same as the checkout page, so Ill leave it for you to implement as an exercise. What well do instead is refactor the code because, as youve probably noticed, theres a lot of repetition going on, especially on the test files. Repetition isnt necessarily bad, especially on test files, because it usually makes it easier for the reader to grasp whats 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, well focus on refactoring the project code. This is where having some test codes really shines because you can just run the tests after youve refactored the code. This allows you to easily check whether youve broken something. This applies not just to refactoring but also updating existing features. Youll know immediately that youve 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 MBOK (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_pageThe 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:86phpvfscomposer:///Users/wernancheta/projects/food-order-app-laravel-tdd/vendor/phpunit/phpunit/phpunit:972) Tests\Feature\CartTest::item_can_be_removed_from_the_cartFailed asserting that '





pre.sf-dump {
display: none !important;
}







Undefined offset: 0






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

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

<?phpnamespace 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, lets refactor the code for the search controller. It currently looks like this:

<?php// app/Http/Controller/SearchController.phpnamespace 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.phpnamespace 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.phpnamespace 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, youll notice that were manipulating or getting data from the cart session a lot:

<?php// app/Http/Controllers/CartController.phpnamespace 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.phpnamespace 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');    }}

Heres 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.phpnamespace 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 weve just created:

<?php// app/Http/Controllers/CheckoutController.phpnamespace 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');    }}

Lets proceed with refactoring. To do this, we can update the get method in the cart service to include a subtotal:

// app/Services/CartService.phppublic 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.phppublic 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.phpnamespace 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, youll 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, youll 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

Thats it! In this tutorial, youve 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.

Theres 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 its 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.


Original Link: https://dev.to/honeybadger/building-a-laravel-app-with-tdd-2a5f

Share this article:    Share on Facebook
View Full Article

Dev To

An online community for sharing and discovering great ideas, having debates, and making friends

More About this Source Visit Dev To