Laravel 13 测试进阶指南

测试是保证代码质量的重要手段。Laravel 13 提供了丰富的测试工具和方法,本文将深入探讨 Laravel 13 的高级测试技巧。

测试环境配置

PHPUnit 配置

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
<!-- phpunit.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
</php>
</phpunit>

特性测试

基础测试类

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

namespace Tests\Feature;

use Tests\TestCase;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;

class UserTest extends TestCase
{
use RefreshDatabase;

public function test_user_can_be_created(): void
{
$response = $this->postJson('/api/users', [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password123',
]);

$response->assertStatus(201)
->assertJson([
'message' => 'User created successfully',
]);

$this->assertDatabaseHas('users', [
'email' => 'john@example.com',
]);
}
}

认证测试

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

namespace Tests\Feature;

use Tests\TestCase;
use App\Models\User;
use Laravel\Sanctum\Sanctum;

class AuthenticatedTest extends TestCase
{
public function test_authenticated_user_can_access_protected_route(): void
{
$user = User::factory()->create();

Sanctum::actingAs($user);

$response = $this->getJson('/api/user');

$response->assertStatus(200)
->assertJson([
'id' => $user->id,
'email' => $user->email,
]);
}

public function test_unauthenticated_user_cannot_access_protected_route(): void
{
$response = $this->getJson('/api/user');

$response->assertStatus(401);
}
}

单元测试

服务测试

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

namespace Tests\Unit\Services;

use Tests\TestCase;
use App\Services\PaymentService;
use App\Models\Order;
use Mockery;

class PaymentServiceTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}

public function test_process_payment_successfully(): void
{
$order = Order::factory()->make(['total' => 100.00]);

$gateway = Mockery::mock('App\Contracts\PaymentGateway');
$gateway->shouldReceive('charge')
->once()
->with($order->total, $order->id)
->andReturn(['status' => 'success', 'transaction_id' => 'txn_123']);

$service = new PaymentService($gateway);

$result = $service->process($order);

$this->assertEquals('success', $result['status']);
$this->assertEquals('txn_123', $result['transaction_id']);
}
}

模型测试

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

namespace Tests\Unit\Models;

use Tests\TestCase;
use App\Models\User;
use App\Models\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;

class UserTest extends TestCase
{
use RefreshDatabase;

public function test_user_has_many_posts(): void
{
$user = User::factory()->create();
$posts = Post::factory()->count(3)->create(['user_id' => $user->id]);

$this->assertCount(3, $user->posts);
$this->assertInstanceOf(Post::class, $user->posts->first());
}

public function test_user_full_name_attribute(): void
{
$user = User::factory()->create([
'first_name' => 'John',
'last_name' => 'Doe',
]);

$this->assertEquals('John Doe', $user->full_name);
}

public function test_user_email_is_verified(): void
{
$user = User::factory()->create([
'email_verified_at' => now(),
]);

$this->assertTrue($user->hasVerifiedEmail());
}
}

HTTP 测试

请求测试

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
public function test_get_users_list(): void
{
User::factory()->count(10)->create();

$response = $this->getJson('/api/users');

$response->assertStatus(200)
->assertJsonCount(10, 'data')
->assertJsonStructure([
'data' => [
'*' => ['id', 'name', 'email', 'created_at']
],
'meta' => ['total', 'per_page', 'current_page']
]);
}

public function test_create_user_validation(): void
{
$response = $this->postJson('/api/users', [
'name' => '',
'email' => 'invalid-email',
'password' => 'short',
]);

$response->assertStatus(422)
->assertJsonValidationErrors(['name', 'email', 'password']);
}

public function test_update_user(): void
{
$user = User::factory()->create();

$response = $this->putJson("/api/users/{$user->id}", [
'name' => 'Updated Name',
]);

$response->assertStatus(200);

$this->assertDatabaseHas('users', [
'id' => $user->id,
'name' => 'Updated Name',
]);
}

public function test_delete_user(): void
{
$user = User::factory()->create();

$response = $this->deleteJson("/api/users/{$user->id}");

$response->assertStatus(204);

$this->assertDatabaseMissing('users', [
'id' => $user->id,
]);
}

数据库测试

数据库断言

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public function test_database_assertions(): void
{
$user = User::factory()->create();

$this->assertDatabaseHas('users', [
'email' => $user->email,
]);

$this->assertDatabaseCount('users', 1);

$this->assertDatabaseMissing('users', [
'email' => 'nonexistent@example.com',
]);

$user->delete();

$this->assertSoftDeleted('users', [
'id' => $user->id,
]);
}

数据工厂

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
// database/factories/UserFactory.php
<?php

namespace Database\Factories;

use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;

class UserFactory extends Factory
{
protected $model = User::class;

public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'password' => Hash::make('password'),
'email_verified_at' => now(),
];
}

public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}

public function admin(): static
{
return $this->state(fn (array $attributes) => [
'role' => 'admin',
]);
}
}

// 使用
$user = User::factory()->create();
$users = User::factory()->count(10)->create();
$admin = User::factory()->admin()->create();
$unverified = User::factory()->unverified()->create();

模拟和存根

模拟门面

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

namespace Tests\Feature;

use Tests\TestCase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Mail;
use App\Jobs\ProcessPodcast;
use App\Mail\WelcomeEmail;

class MockTest extends TestCase
{
public function test_job_is_dispatched(): void
{
Queue::fake();

$this->postJson('/api/podcasts', [
'title' => 'Test Podcast',
'url' => 'https://example.com/podcast.mp3',
]);

Queue::assertPushed(ProcessPodcast::class);
Queue::assertPushedOn('podcasts', ProcessPodcast::class);
Queue::assertPushed(function (ProcessPodcast $job) {
return $job->podcast->title === 'Test Podcast';
});
}

public function test_mail_is_sent(): void
{
Mail::fake();

$user = User::factory()->create();

$this->postJson('/api/register', [
'name' => $user->name,
'email' => $user->email,
'password' => 'password',
]);

Mail::assertSent(WelcomeEmail::class, function ($mail) use ($user) {
return $mail->hasTo($user->email);
});
}

public function test_event_is_dispatched(): void
{
Event::fake();

$order = Order::factory()->create();
$this->postJson("/api/orders/{$order->id}/ship");

Event::assertDispatched(OrderShipped::class);
Event::assertDispatched(function (OrderShipped $event) use ($order) {
return $event->order->id === $order->id;
});
}
}

模拟服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public function test_with_mocked_service(): void
{
$this->mock(PaymentService::class, function ($mock) {
$mock->shouldReceive('process')
->once()
->andReturn(['status' => 'success']);
});

$response = $this->postJson('/api/orders', [
'amount' => 100,
]);

$response->assertStatus(201);
}

测试数据

测试数据提供者

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

namespace Tests\Feature;

use Tests\TestCase;

class UserValidationTest extends TestCase
{
public static function invalidEmailProvider(): array
{
return [
'empty email' => [''],
'invalid format' => ['invalid-email'],
'missing domain' => ['user@'],
'missing tld' => ['user@example'],
];
}

#[\PHPUnit\Framework\Attributes\DataProvider('invalidEmailProvider')]
public function test_invalid_email_is_rejected(string $email): void
{
$response = $this->postJson('/api/users', [
'name' => 'John Doe',
'email' => $email,
'password' => 'password123',
]);

$response->assertStatus(422)
->assertJsonValidationErrors(['email']);
}
}

测试中间件

测试认证中间件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public function test_authentication_required(): void
{
$response = $this->getJson('/api/user');

$response->assertStatus(401);
}

public function test_authenticated_request(): void
{
$user = User::factory()->create();

$response = $this->actingAs($user)
->getJson('/api/user');

$response->assertStatus(200);
}

测试授权中间件

1
2
3
4
5
6
7
8
9
public function test_user_cannot_access_admin_routes(): void
{
$user = User::factory()->create(['role' => 'user']);

$response = $this->actingAs($user)
->getJson('/api/admin/users');

$response->assertStatus(403);
}

测试命令

测试 Artisan 命令

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

namespace Tests\Feature\Console;

use Tests\TestCase;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;

class SendEmailsCommandTest extends TestCase
{
use RefreshDatabase;

public function test_command_sends_emails(): void
{
User::factory()->count(5)->create();

Mail::fake();

$this->artisan('emails:send')
->expectsOutput('Sending emails...')
->assertExitCode(0);

Mail::assertSent(NewsletterEmail::class, 5);
}
}

测试最佳实践

1. 测试命名规范

1
2
3
4
5
6
7
8
9
// 好的做法
public function test_user_can_create_post(): void {}
public function test_user_cannot_delete_others_post(): void {}
public function test_email_must_be_valid(): void {}

// 不好的做法
public function testCreate(): void {}
public function testDelete(): void {}
public function testEmail(): void {}

2. 测试结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public function test_user_can_create_post(): void
{
// Arrange
$user = User::factory()->create();
$postData = [
'title' => 'Test Post',
'content' => 'Test content',
];

// Act
$response = $this->actingAs($user)
->postJson('/api/posts', $postData);

// Assert
$response->assertStatus(201);
$this->assertDatabaseHas('posts', $postData);
}

3. 使用测试辅助方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
protected function actingAsUser(?User $user = null): User
{
$user = $user ?? User::factory()->create();
$this->actingAs($user);
return $user;
}

protected function createPost(array $attributes = []): Post
{
return Post::factory()->create($attributes);
}

public function test_user_can_view_own_post(): void
{
$user = $this->actingAsUser();
$post = $this->createPost(['user_id' => $user->id]);

$response = $this->getJson("/api/posts/{$post->id}");

$response->assertStatus(200);
}

运行测试

命令行选项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 运行所有测试
php artisan test

# 运行特定文件
php artisan test tests/Feature/UserTest.php

# 运行特定方法
php artisan test --filter test_user_can_create_post

# 并行运行
php artisan test --parallel

# 生成覆盖率报告
php artisan test --coverage

# 只运行失败的测试
php artisan test --rerun-filter

总结

Laravel 13 提供了强大而全面的测试支持。通过合理使用特性测试、单元测试、模拟和存根等工具,可以构建出高质量的测试套件。记住遵循 AAA 模式(Arrange、Act、Assert),使用有意义的测试命名,并保持测试的独立性和可重复性。良好的测试覆盖率是代码质量的保障,也是重构的信心来源。