TDD 构建 Laravel 应用之项目任务

本系列文章为 laracasts.com 的系列视频教程 ——Build A Laravel App With TDD 的学习笔记,若喜欢该系列视频,可去该网站订阅后下载该系列视频, 支持正版

任务测试

  • 提取登录方法:

之前在项目功能测试中许多地方都使用到了:

1
2
3
$this->actingAs(factory('App\User')->create());
# 或者
$this->be(factory('App\User')->create());

来表示登录用户,在任务测试中还将继续使用。所以我们可以提取这个方法,在 TestCase.php 中写入如下:

1
2
3
4
5
6
7
8
9
10
...
abstract class TestCase extends BaseTestCase
{
use CreatesApplication;

protected function signIn($user = null)
{
return $this->actingAs($user ?: factory('App\User')->create());
}
}

替换 ManageProjectsTest.php 中对应的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
<?php

namespace Tests\Feature;

use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;

class ManageProjectsTest extends TestCase
{
use WithFaker, RefreshDatabase; //生成假数据, 每次测试后重置数据库

/** @test */
public function guests_cannot_manage_projects()
{
$project = factory('App\Project')->create();

$this->get('/projects')->assertRedirect('login');
$this->get('/projects/create')->assertRedirect('login');
$this->get($project->path())->assertRedirect('login');
$this->post('/projects', $project->toArray())->assertRedirect('login');
}

/** @test */
public function a_user_can_create_a_project()
{
$this->withoutExceptionHandling();

$this->signIn();

$this->get('/projects/create')->assertStatus(200);

$attributes = [
'title' => $this->faker->sentence,
'description' => $this->faker->paragraph
];

$this->post('/projects', $attributes)->assertRedirect('/projects');

// 断言数据库中的数据是否和给定数据集合匹配
$this->assertDatabaseHas('projects', $attributes);

$this->get('/projects')->assertSee($attributes['title']);
}

/** @test */
public function a_user_can_view_their_project()
{
$this->signIn();

$this->withoutExceptionHandling();

$project = factory('App\Project')->create(['owner_id'=> auth()->id()]);

$this->get($project->path())
->assertSee($project->title)
->assertSee($project->description);
}

/** @test */
public function an_authenticated_user_cannot_view_the_projects_of_others()
{
$this->signIn();

$project = factory('App\Project')->create();

$this->get($project->path())->assertStatus(403);

}

/** @test */
public function a_project_requires_title()
{
$this->signIn();
$attributes = factory('App\Project')->raw(['title' => '']);
$this->post('/projects', $attributes)->assertSessionHasErrors('title');
}

/** @test */
public function a_project_requires_description()
{
$this->signIn();
$attributes = factory('App\Project')->raw(['description' => '']);
$this->post('/projects', $attributes)->assertSessionHasErrors('description');
}

}

运行测试成功!

  • 一个项目拥有 N 个任务。测试先行:
1
make:test ProjectTasksTest
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?php

namespace Tests\Feature;

use App\Project;
use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;

class ProjectTasksTest extends TestCase
{
use RefreshDatabase;

/** @test */
public function a_project_can_have_tasks()
{
$this->withoutExceptionHandling();

$this->signIn();

$project = factory(Project::class)->create(['owner_id' => auth()->id()]);

$this->post($project->path() .'/tasks', ['body' => 'Test task']);

$this->get($project->path())->assertSee('Test task');
}
}

测试运行得出错误:

1
2
1) Tests\Feature\ProjectTasksTest::a_project_can_have_tasks
Symfony\Component\HttpKernel\Exception\NotFoundHttpException: POST http://birdboard.test/projects/1/tasks

找不到路由,增加路由:

1
2
3
...
Route::post('/projects', 'ProjectsController@store');
Route::post('/projects/{project}/tasks', 'ProjectTasksController@store');

运行测试结果如下:

1) Tests\Feature\ProjectTasksTest::a_project_can_have_tasks
ReflectionException: Class App\Http\Controllers\ProjectTasksController does not exist

生成控制器补全方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php

namespace App\Http\Controllers;

use App\Project;

class ProjectTasksController extends Controller
{
public function store(Project $project)
{
$project->addTask(request('body'));

return redirect($project->path());
}
}

运行测试结果如下:

1) Tests\Feature\ProjectTasksTest::a_project_can_have_tasks
BadMethodCallException: Call to undefined method App\Project::addTask()

在单元测试 ProjectTest 中写一个关于 addTask 的测试:

1
2
3
4
5
6
7
8
9
/** @test */
public function it_can_add_a_task()
{
$project = factory('App\Project')->create();

$project->addTask('Test task');

$this->assertCount(1, $project->tasks);
}

运行测试结果如下:

1) Tests\Unit\ProjectTest::it_can_add_a_task
BadMethodCallException: Call to undefined method App\Project::addTask()

补全 addTask() 方法:

1
2
3
4
public function addTask($body)
{
return $this->tasks()->create(compact('body'));
}

运行测试结果如下:

1) Tests\Unit\ProjectTest::it_can_add_a_task
BadMethodCallException: Call to undefined method App\Project::tasks()

补全 tasks() 方法:

1
2
3
4
public function tasks()
{
return $this->hasMany(Task::class);
}

还没有 Task 模型及迁移文件,生成:

1
php artisan make:model -m Task
  • 完善模型和迁移文件:
1
2
3
4
5
6
7
8
9
10
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Task extends Model
{
protected $guarded = [];
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateTasksTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('tasks', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('project_id');
$table->text('body');
$table->timestamps();
});
}

/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('tasks');
}
}

ProjectTest单元测试通过

  • 优化之前的单元测试:
1
2
3
4
5
6
7
8
9
10
/** @test */
public function it_can_add_a_task()
{
$project = factory('App\Project')->create();

$task = $project->addTask('Test task');

$this->assertCount(1, $project->tasks);
$this->assertTrue($project->tasks->contains($task));
}

ProjectTest单元测试通过

  • 在项目详情页注入任务,代码如下:
1
2
3
4
5
6
<div class="mb-8">
<h2 class="text-lg text-gray-500 text-sm font-normal mb-3">Tasks</h2>
@foreach($project->tasks as $task)
<div class="card mb-3">{{ $task->body }}</div>
@endforeach
</div>

运行测试通过

  • 优化 a_project_can_have_tasks 方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/** @test */
public function a_project_can_have_tasks()
{

$this->signIn();

$project = auth()->user()->projects()->create(
factory(Project::class)->raw()
);

$this->post($project->path() .'/tasks', ['body' => 'Test task']);

$this->get($project->path())->assertSee('Test task');
}

测试运行通过

  • 测试 body 必填项:
1
2
3
4
5
6
7
8
9
10
11
12
/** @test */
public function a_task_requires_a_body()
{
$this->signIn();

$project = auth()->user()->projects()->create(
factory(Project::class)->raw()
);

$attributes = factory('App\Task')->raw(['body'=>'']);
$this->post($project->path() .'/tasks', $attributes)->assertSessionHasErrors('body');
}

测试结果如下:

1) Tests\Feature\ProjectTasksTest::a_task_requires_a_body
InvalidArgumentException: Unable to locate factory with name [default] [App\Task].

生成 Task 数据工厂:

1
php artisan make:factory TaskFactory
1
2
3
4
5
6
7
8
9
10
11
<?php

/* @var $factory \Illuminate\Database\Eloquent\Factory */

use Faker\Generator as Faker;

$factory->define(\App\Task::class, function (Faker $faker) {
return [
'body' => $faker->sentence
];
});

测试结果如下:

1) Tests\Feature\ProjectTasksTest::a_task_requires_a_body
Session is missing expected key [errors].
Failed asserting that false is true.

我们之前并没有设置 body 属性是必须的,现在设置上:

1
2
3
4
5
6
7
public function store(Project $project)
{
request()->validate(['body'=> 'required']);
$project->addTask(request('body'));

return redirect($project->path());
}

测试通过