用TDD构建Laravel应用程序
#tdd #php #laravel

本文最初是由
Wern Ancheta
Honeybadger Developer Blog上撰写的。

在本教程中,我将向您展示如何通过从头开始创建一个项目来开始在Laravel的测试驱动开发。遵循本教程后,您应该能够在未来的Laravel项目中应用测试驱动的开发。此外,您将在本教程中学到的概念也应适用于其他编程语言。

我们将在其最基本的水平上创建一个食品订购应用程序。它只有以下功能:

  • 寻找食物
  • 向购物车添加食物
  • 提交订单(请注意,这将包括付款的处理。其唯一目的是在数据库中保存订单信息。)

先决条件

  • PHP开发环境(包括PHP和MySQL)。 Apache或nginx是可​​选的,因为我们可以通过Laravel的工匠命令运行服务器。
  • 节点(这应包括NPM)。
  • 基本的PHP和Laravel经验。

概述

我们将介绍以下主题:

  • TDD工作流
  • 如何使用3相模式编写测试
  • 断言
  • 重构代码

设置Laravel项目

要跟随,您需要克隆GitHub repo并切换到starter分支:

git clone git@github.com:anchetaWern/food-order-app-laravel-tdd.git
cd food-order-app-laravel-tdd
git checkout starter

接下来,安装作曲家依赖项:

composer install

.env.example文件重命名为.env并生成一个新键:

php artisan key:generate

接下来,安装前端依赖项:

npm install

一旦完成,您应该能够运行该项目:

php artisan serve

项目概况

您可以在浏览器上访问该项目,以了解我们要构建的内容:

http://127.0.0.1:8000/

首先,我们有搜索页面,用户可以在其中搜索他们要订购的食物。这是应用程序的默认页面:

Search page

接下来,我们将拥有购物车页面,用户可以在其中看到他们添加到购物车中的所有食物。从这里,他们还可以更新数量或从购物车中删除物品。可以通过/cart路线访问:

Cart page

接下来,我们有“结帐”页面,用户可以在其中提交订单。可以通过/checkout路线访问:

Checkout page

最后,我们有订单确认页面。可以通过/summary路线访问:

Summary page

构建项目

在本教程中,我们将仅专注于事物的后端。这将使我们在项目中实施TDD时能够涵盖更多的基础。因此,我们将涵盖任何前端方面,例如HTML,JavaScript或CSS代码。这就是为什么建议您从starter分支开始的原因。本教程的其余部分将假定您已经准备好所有必要的代码。

使用TDD启动项目时需要牢记的第一件事是,您必须在需要实现的实际功能之前编写测试代码。

这说起来容易做起来难,对吧?当它甚至还不存在时,您如何测试它?这是一厢情愿的编码来进行编码。这个想法是编写测试代码,好像您正在测试的实际代码已经存在。因此,您基本上只是在与之互动,就好像已经存在了一样。之后,您进行测试。当然,它将失败,因此您需要使用错误消息来指导您接下来需要做什么。只需编写最简单的实现来解决测试返回的特定错误,然后再次运行测试即可。重复此步骤直到测试通过。

当您刚开始时,这会感到有些奇怪,但是在编写几十个测试并进行整个周期后,您会习惯它。

创建测试

让创建一个新的测试文件继续进行。我们将按照我之前显示的顺序单独实施每个屏幕。

php artisan make:test SearchTest

这将在/tests/Feature文件夹下创建一个SearchTest.php文件。默认情况下,make:test工匠命令创建了功能测试。这将测试特定功能,而不是特定的代码。以下是一些示例:

  • 在提交带有有效数据的注册表单时测试是否创建用户。
  • 当用户单击删除按钮时,测试是否从购物车中删除产品。
  • 测试用户输入特定查询时是否列出了特定结果。

但是,单位测试用于深入研究使特定功能工作的功能。这种类型的测试与实现特定功能所涉及的代码直接相互作用。例如,在购物车功能中,您可能会有以下单元测试:

  • Cart类中调用add()方法将项目添加到用户的购物车会话中。
  • Cart类中调用remove()方法从用户的购物车会话中删除项目。

打开tests/Feature/SearchTest.php文件时,您将看到以下内容:

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

这将测试访问该应用的主页是否会返回200 HTTP状态代码,这基本上意味着在浏览器上访问网站时,任何用户都可以访问该页面。

运行测试

要运行测试,执行以下命令;这将使用PHP单元测试跑者运行所有测试:

vendor/bin/phpunit

这将返回以下输出。除了我们刚刚创建的测试之外,已经有一个默认功能和单元测试,这就是为什么有3个测试和3个断言:

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)

删除tests/Featuretests/Unit文件夹中的默认一个,因为我们不需要它们。

如果要运行特定的测试文件,则可以提供--filter选项,并添加类名称或方法名称:

vendor/bin/phpunit --filter SearchTest
vendor/bin/phpunit --filter test_example

这是您需要记住的唯一命令。显然,很难一遍又一遍地输入整个内容,因此请添加一个别名。在项目根目录中执行这两个命令:

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

一旦完成,您应该能够做到这一点:

p
pf Searchtest
pf test_example

搜索测试

现在,我们终于准备为搜索页面编写一些实际测试。清除现有测试,以便我们可以从干净的板岩开始。您的tests/Feature/SearchTest.php文件应该看起来像这样:

<?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
}

开始,让我们写一个测试以确定主页是否可以访问。正如您之前了解到的,首页基本上是食品搜索页面。您可以通过两种方式编写测试方法;首先是添加测试注释:

// tests/Feature/SearchTest.php

/** @test */
public function food_search_page_is_accessible()
{
    $this->get('/')
        ->assertOk();
}

另外,您可以将其前缀为test_

// tests/Feature/SearchTest.php

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

然后可以通过提供方法名称作为过滤器的值来执行两种方式。只需省略test_前缀,如果您采用替代方式:

pf food_search_page_is_accessible

为了保持一致性,我们将在本教程的其余部分中使用/** @test */注释。这样做的优点是,您不仅限于测试方法名称中的“测试”一词。这意味着您可以提出更多的描述性名称。

至于命名测试方法,无需过度思考。只需使用最好,最简洁的方法来描述您的测试。这些只是测试方法,因此您可以具有很长的方法名称,只要它清楚地描述了您的测试。

如果您已切换到存储库的starter分支,您会发现我们已经为测试提供了必要的代码:

// routes/web.php
Route::get('/', function () {
    return view('search');
});

下一步是添加另一个测试,以证明搜索页面具有所有必要的页面数据。这是写作测试时使用的三相图案所使用的地方:

  1. 安排
  2. 行为
  3. Assrt

安排阶段

首先,我们需要安排我们的测试将在其中运作的世界。这通常包括在数据库中保存测试所需的数据,设置会话数据以及应用程序运行所需的其他内容。在这种情况下,我们已经知道我们将使用MySQL数据库存储食物订购应用程序的数据。这就是为什么在安排阶段,我们需要将食物数据添加到数据库中。在这里,我们可以通过一厢情愿的思考来进行编码(没有双关语)。

在测试文件的顶部(命名空间和类之间的任何位置),导入将代表表格的模型,我们将在其中存储要在搜索页面中显示的产品:

// tests/Feature/SearchTest.php

use Tests\TestCase;
use App\Models\Product; // add this

接下来,创建另一种将在数据库中创建3个产品的测试方法:

// 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

}

运行测试,您应该看到类似于以下的错误:

1) Tests\Feature\SearchTest::food_search_page_has_all_the_required_page_data
Error: Class 'App\Models\Product' not found

从这里开始,您需要做的就是尝试尽可能地解决错误。这里的关键是要做的事情要比摆脱当前错误所需的要做更多的事情。在这种情况下,您需要做的就是生成Product模型类,然后再次运行测试。

php artisan make:model Product

然后应该向您显示以下错误:

1) Tests\Feature\SearchTest::food_search_page_has_all_the_required_page_data
Error: Class 'Database\Factories\ProductFactory' not found

再次执行所需的最小步骤,然后再次运行测试:

php artisan make:factory ProductFactory

此时,您应该得到以下错误:

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'

这是有道理的,因为我们尚未设置数据库。继续使用正确的数据库凭据:
来更新项目的.env文件

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=food_order
DB_USERNAME=root
DB_PASSWORD=

您还需要使用数据库客户端创建相应的数据库。完成此操作后,再次进行测试,您应该得到以下错误:

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

逻辑下一步是创建一个迁移:

php artisan make:migration create_products_table

显然,迁移不会自行运行,并且表需要创建一些字段。因此,我们需要先更新迁移文件并在再次运行测试之前运行:

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

完成更新迁移文件后:

php artisan migrate

现在,在运行测试后,您应该看到以下错误:

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

这将我们带到了产品工厂,我们较早地离开了该工厂。请记住,在测试中,我们使用产品工厂为“安排阶段”创建必要的数据。更新产品工厂,以生成一些默认数据:

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

保存更改后,再次运行测试,您应该看到以下内容:

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.

行动阶段

这向我们发出信号,表明已经完成了应用程序运行所需的所有必要设置。我们现在应该能够继续进行“行为阶段”。在此阶段是我们使测试执行特定操作以测试功能。在这种情况下,我们要做的就是访问主页:

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

}

断言阶段

毫无意义地进行测试,因此请继续添加断言阶段。这是我们测试“ ACT阶段的响应”是否与我们期望的相匹配的地方。在这种情况下,我们要证明所使用的视图是search视图,并且它具有所需的items数据:

// tests/Feature/SearchTest.php

// Assert
$items = Product::get();

$response->assertViewIs('search')->assertViewHas('items', $items);

运行测试后,您会看到我们的第一个真实问题,这些问题与应用程序设置无关:

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

再次进行测试,只投资最少的努力:

// routes/web.php

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

此时,您现在将进行第一次通过测试:

OK (1 test, 2 assertions)

重构代码

下一步是重构代码。我们不希望将所有代码放入路线文件中。测试通过后,下一步就是重构代码,以便遵循编码标准。在这种情况下,您需要做的就是创建一个控制器:

php artisan make:controller SearchProductsController

然后,在您的控制器文件中,添加以下代码:

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

不要忘记更新您的路线文件:

// routes/web.php

use App\Http\Controllers\SearchProductsController;

Route::get('/', [SearchProductsController::class, 'index']);

我们刚刚完成了使用TDD实施新功能的整个过程。在这一点上,您现在对TDD的完成有了一个很好的了解。因此,我将不再像上面那样走动您。我唯一的目的是让您使用工作流程。从这里开始,我将仅解释测试代码和实现,而无需完成整个工作流程。

以前的测试没有证明该页面显示用户需要查看的项目。该测试使我们能够证明它:

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

要通过上述测试,只需循环穿过$items并显示所有相关字段:

<!-- 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>

我们需要在此页面上测试的最后一件事是搜索功能。因此,我们需要在安排阶段对食物名称进行核对。我们可以像以前的测试一样轻松地通过索引来指出它们。通过这样做,您将节省很多击键,这也将是一个完全有效的测试。但是,大多数情况下,您需要考虑以后查看此代码的人。呈现代码的最佳方法是什么,以便他或她很容易理解您要测试的内容?在这种情况下,我们试图测试特定项目是否会显示在页面上,因此最好硬编码测试中的名称,以便可以轻松可视化:

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

此外,您还希望断言当查询未传递时仍然可以看到整个项目列表:

// tests/Feature/SearchTest.php

$this->get('/')->assertSeeInOrder(['Taco', 'Pizza', 'BBQ']);

您可以更新控制器,如果请求中提供了查询,则可以过滤结果:

然后,在控制器文件中,更新代码,以便使用查询来过滤结果:

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

不要忘记更新视图,以便它具有接受用户输入的表格:

<!-- 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)
    ...

这是这种测试的弱点之一,因为它并不容易验证页面中存在形式。有assertSee method,但是使用HTML验证不建议,因为它可能经常根据设计或复制更改对其进行更新。对于这些类型的测试,您最好使用Laravel Dusk。但是,这是本教程的范围。

分开测试数据库

在我们继续之前,请注意数据库只是继续填写数据。我们不希望发生这种情况,因为它可能会影响测试结果。为了防止不洁数据库引起的问题,我们希望在执行每个测试之前从数据库中清除数据。我们可以使用RefreshDatabase特征来做到这一点,该特征在运行测试时迁移您的数据库。请注意,它仅执行一次,而不是每次测试。相反,对于每个测试,它将包括您在一次交易中进行的所有数据库调用。然后,它在运行每个测试之前将其滚回去。这有效地撤消了每个测试中所做的更改:

// 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..
}

尝试再次运行所有测试,并注意您的数据库在其末尾是空的。

再次,这不是理想的选择,因为您可能需要通过浏览器手动测试您的应用程序。手动测试时,所有数据一直都会很痛苦。

因此,解决方案是创建一个单独的数据库。您可以通过登录MySQL控制台来做到这一点:

mysql -u root -p

然后,创建一个专门用于测试的数据库:

CREATE DATABASE food_order_test;

接下来,在项目目录的根部创建一个.env.testing文件,然后输入与.env文件相同的内容。您唯一需要更改的是DB_DATABASE配置:

DB_DATABASE=food_order_test

就是这样!尝试首先将一些数据添加到主数据库中,然后再次运行测试。您添加到主数据库中的数据仍然是完整的,因为PHPUNIT现在使用测试数据库。您可以在主数据库上运行以下查询以测试内容:

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

另外,您可以将数据库播种机从tdd分支复制到您的项目:

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

一定要在数据库播种机中调用ProductSeeder

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

完成后,运行php artisan db:seed将数据库用默认数据播种。

购物车测试

下一步是测试和实现购物车功能。

首先生成一个新的测试文件:

php artisan make:test CartTest

首先,测试是否可以将项目添加到购物车中。要开始写这篇文章,您需要假设端点已经存在。向该端点提出请求,然后检查是否已更新会话以包括您传递给请求的项目:

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

在上面的代码中,我们向/cart端点提交了POST请求。我们作为第二个参数传递的数组模拟了浏览器中的形式数据。可以通过控制器中的通常方式访问这一点,因此它将与您提交实际表格请求相同。然后,我们使用了三个新断言:

  • assertRedirect-断言,一旦提交表单,服务器就将服务器重定向到特定的端点。
  • assertSessionHasErrors-断言服务器没有通过闪存会话返回任何错误。这通常用于验证没有形式验证错误。
  • assertSessionHas-断言会话中有特定的数据。如果是数组,您可以使用索引来参考要检查的特定索引。

运行测试将导致您创建路由,然后将项目添加到购物车中的控制器:

// routes/web.php
use App\Http\Controllers\CartController;

Route::post('/cart', [CartController::class, 'store']);

生成控制器:

php artisan make:controller CartController

然后,添加将项目推入购物车的代码:

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

这些步骤应进行测试。但是,问题在于,我们没有更新搜索视图,尚未让用户向购物车添加一个项目。如前所述,我们可以对此进行测试。现在,让我们只更新视图以包括在购物车中添加项目的表格:

<!-- 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>

简单地将新物品推入购物车就不够了,因为用户可能会再次添加同一项目,我们不想发生。相反,我们希望用户增加他们先前添加的项目的数量。

这里的测试:

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

}

显然,这将失败,因为我们没有检查重复的项目。更新store()方法以包括用于检查现有项目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');
}

接下来,测试以查看是否使用正确的视图来显示购物车页面:

// tests/Feature/CartTest.php

/** @test */
public function cart_page_can_be_accessed()
{
    Product::factory()->count(3)->create();

    $this->get('/cart')
        ->assertViewIs('cart');

}

上面的测试将通过无需执行任何操作,因为我们仍然具有从开始代码的现有路线。

接下来,我们要验证可以从购物车页面上看到添加到购物车中的项目。下面,我们使用了一个名为assertSeeTextInOrder()的新断言。这接受了您期望按正确顺序在页面上看到的一系列字符串。在这种情况下,我们添加了炸玉米饼,然后添加了烧烤,因此我们将检查此特定顺序:

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

}

您可能想知道为什么我们没有检查其他产品数据,例如成本或数量。您当然可以,但是在这种情况下,仅看到产品名称就足够了。我们将在此检查中对此进行另一项测试。

添加用于将购物车页面返回控制器的代码:

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

相应地更新路线文件:

// routes/web.php

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

然后,更新视图文件,使其显示购物车项目:

<!-- 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>

这应该进行测试。

接下来,我们测试是否可以从购物车中删除购物车。这是我前面提到的其他测试,它将验证从页面可以看到相应的成本和数量。在下面的代码中,我们在将商品添加到购物车中时采用了快捷方式。我们没有直接构建cart会话来添加每个项目。这是一种完全有效的方法,尤其是如果您的其他测试已经验证了在购物车中添加物品的工作。最好以这种方式进行操作,以便每个测试只关注它需要测试的内容:

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

}

上述测试将失败,因为我们还没有终点。继续进行更新:

// routes/web.php

Route::delete('/cart/{id}', [CartController::class, 'destroy']);

然后,更新控制器:

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

目前,测试将成功,尽管我们没有更新视图,以便它接受此特定表格请求的提交。这是我们早些时候检查为购物车添加项目的功能时遇到的同一问题。因此,我们将无法处理如何处理这个问题。目前,只需更新购物车视图即可包括提交到负责从购物车中删除物品的端点的表格:

<!-- 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

接下来,添加测试以检查购物车项目的数量是否可以更新:

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

}

要进行测试,请先更新路线:

// routes/web.php

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

然后,更新控制器,以便在请求中找到传递的项目并更新其数量:

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

这些步骤应进行测试,但是我们仍然存在视图的问题,不允许用户提交此特定请求。因此,我们需要再次更新它:

<!-- 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

结帐测试

让我们进行结帐测试,我们在其中验证结帐功能是否正常工作。生成测试文件:

php artisan make:test CheckoutTest

首先,我们需要检查是否可以在结帐页面上看到添加到购物车的项目。这里没有什么新的;我们要做的只是验证该视图是否具有预期数据,并且在页面上显示它们:

<?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
            ]);
    }
}

此测试将失败,因此您需要创建控制器:

php artisan make:controller CheckoutController

将以下代码添加到控制器。这与我们对Cart Controller的index方法所做的非常相似。唯一的区别是我们现在为每个项目都有一个subtotal,然后将它们全部总结在total变量中:

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

不要忘记更新路线文件:

// routes/web.php

use App\Http\Controllers\CheckoutController;

Route::get('/checkout', [CheckoutController::class, 'index']); // replace existing code from starter

此外,更新视图文件:

<!-- 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>

我们要测试的最后一件事是创建订单。如前所述,我们将在该项目中处理付款。相反,我们将仅在数据库中创建顺序。这次,我们的安排阶段涉及添加,更新和删除购物车的端点。这违背了我之前提到的内容,您应该只做针对您重新测试的事情的设置。这就是我们之前为item_can_be_removed_from_the_cartcart_item_qty_can_be_updated测试所做的。我们没有直接更新会话。

,而是直接更新了会话。

总是每个规则的例外。在这种情况下,我们需要点击端点,而不是直接操纵会话,以便我们可以测试整个结帐流是否按预期工作。向/checkout端点提出请求后,我们希望数据库包含特定的记录。为了验证这一点,我们使用assertDatabaseHas(),该assertDatabaseHas()接受表的名称作为其第一个参数和您期望看到的列值对。请注意,这仅接受一行,因此,如果要验证多个行,则必须多次调用它:

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

要进行测试通行证,请将create方法添加到结帐控制器中。在这里,我们基本上做了同样的事情,我们在index方法中做了同样的事情。但是,这次我们将总和详细信息保存到了相应的表:

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

对于上述代码的工作,我们需要生成一个迁移文件来创建ordersorder_details表:

php artisan make:migration create_orders_table
php artisan make:migration create_order_details_table

这是创建订单表迁移文件的内容:

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

这是创建订单详细信息表迁移文件的内容:

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

运行迁移:

php artisan migrate

接下来,我们需要为两个表生成模型:

php artisan make:model Order
php artisan make:model OrderDetail

这是订单模型的代码:

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

以及订单详细模型的代码:

<?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;
}

此外,更新路由文件:

// routes/web.php

Route::post('/checkout', [CheckoutController::class, 'create']);

重构代码

我们将在这里总结实现功能。还有另一个页面(摘要页面),但是该页面与结帐页面几乎相同,因此我将其保留给您作为练习。相反,我们要做的是重构代码,因为您可能会注意到,重复很多,尤其是在测试文件上。重复不一定是不好的,尤其是在测试文件上,因为它通常使读者更容易掌握一眼的情况。这是将逻辑隐藏在方法中的对立面,以便您可以节省几行代码。

因此,在本节中,我们将重点放在重构项目代码上。在这里,有一些测试代码确实会发光,因为您可以在重新分配代码后才运行测试。这使您可以轻松检查是否破裂。这不仅适用于重构,还适用于更新现有功能。您会立即知道您不必去浏览器并手动测试东西。

刷新数据库的问题

在进行继续之前,请确保所有测试都通过:

vendor/bin/phpunit

您应该看到以下输出:

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

Time: 00:00.442, Memory: 30.00 MB

OK (12 tests, 37 assertions)

如果没有,那么您很可能会看到一些看起来像这样的胡言乱语:

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

这不是完整的错误,而是您明白了。问题是RefreshDatabase无法正常工作,每次测试之间有一些数据徘徊,这导致其他测试失败。解决方案是让Phpunit在运行每个测试后自动截断所有表。您可以通过更新tests/TestCase.php文件中的tearDown()方法来做到这一点:

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

}

完成后,所有测试都应通过。

重构产品搜索代码

首先,让S搜索控制器的代码重构。目前看起来像这样:

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

如果我们可以将查询逻辑封装在雄辩的模型本身中,这样我们就可以做这样的事情。这样,我们可以在其他地方重复使用相同的查询:

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

我们可以通过在模型中添加matches方法来做到这一点:

<?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}%");
        });
    }
}

重构购物车代码

接下来,我们有购物车控制器。如果您只是滚动该文件,您会注意到我们经常操纵或从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 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');
    }
}

如果我们可以将所有这些逻辑封装在服务类中,那将是很好的。这样,我们可以在结帐控制器中重复使用相同的逻辑:

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

这是购物车服务的代码。在app目录中创建一个Services文件夹,然后创建一个CartService.php文件:

<?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]);
    }
}

重构结帐代码

最后,我们有结帐控制器,它可以从我们刚创建的购物车服务中使用一些帮助:

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

让我进行重构。为此,我们可以更新购物车服务中的get方法以包括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();
}

我们还需要添加一种total方法才能获取购物车:

// app/Services/CartService.php

public function total()
{
    $items = collect($this->get());
    return $items->sum('subtotal');
}

您可以更新结帐控制器以使用购物车服务:

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

请注意,如果您在此时运行整个测试套件,则在items_added_to_the_cart_can_be_seen_in_the_cart_page上会出现错误,因为我们的预期视图数据在添加subtotal字段后更改。为了进行测试,您将需要以期望值添加此字段:

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

}

结论和下一步

就是这样!在本教程中,您通过构建一个现实世界的应用程序了解了Laravel的测试驱动开发的基础。具体来说,您了解了TDD工作流程,每个测试使用的3阶段模式以及可以使用一些主张来验证特定功能是否应其应有的方式工作。到现在为止,您应该拥有使用TDD开始构建未来项目所需的基本工具。

如果您想能够使用TDD编写您的项目,那么仍有很多东西要学习。其中很大一部分是嘲笑,这是您在测试中交换伪造的特定功能的伪造实现,以便更容易运行。 Laravel已经包括框架提供的普通功能的假货。这包括存放伪造,队列假和伪造的公共汽车等。您可以阅读official documentation以了解有关它的更多信息。您还可以在其GitHub repo上查看该应用程序的源代码。