MidПрактика8 min

HTTP-тесты

get/post/put/delete, assertStatus, assertJson, assertSee, assertRedirect, тестирование загрузки файлов, API тестирование, аутентификация в тестах

Laravel предоставляет удобный API для выполнения HTTP-запросов к приложению и проверки ответов в тестах.

Базовые HTTP-запросы

<?php

declare(strict_types=1);

namespace Tests\Feature;

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

class ExampleTest extends TestCase
{
    use RefreshDatabase;

    public function test_homepage_returns_success(): void
    {
        $response = $this->get('/');

        $response->assertStatus(200);
    }

    public function test_api_returns_users(): void
    {
        User::factory(3)->create();

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

        $response->assertOk() // assertStatus(200)
            ->assertJsonCount(3, 'data');
    }
}

Все HTTP-методы

<?php

// GET request
$response = $this->get('/users');
$response = $this->getJson('/api/users'); // Sets Accept: application/json

// POST request
$response = $this->post('/users', [
    'name' => 'John',
    'email' => '[email protected]',
]);
$response = $this->postJson('/api/users', [
    'name' => 'John',
    'email' => '[email protected]',
]);

// PUT request
$response = $this->put('/users/1', [
    'name' => 'Updated Name',
]);
$response = $this->putJson('/api/users/1', [
    'name' => 'Updated Name',
]);

// PATCH request
$response = $this->patch('/users/1', [
    'name' => 'Updated Name',
]);
$response = $this->patchJson('/api/users/1', [
    'name' => 'Updated Name',
]);

// DELETE request
$response = $this->delete('/users/1');
$response = $this->deleteJson('/api/users/1');

// OPTIONS request
$response = $this->options('/api/users');

// HEAD request
$response = $this->head('/api/users');

Подвох на экзамене: Методы getJson(), postJson(), putJson() и т.д. автоматически устанавливают заголовки Accept: application/json и Content-Type: application/json. Обычные get(), post() используют HTML-запрос. Для API тестов ВСЕГДА используйте *Json() версии.

Заголовки запроса

<?php

$response = $this->withHeaders([
    'X-Custom-Header' => 'value',
    'Accept-Language' => 'ru',
])->getJson('/api/users');

// Set single header
$response = $this->withHeader('X-Custom', 'value')
    ->getJson('/api/users');

// Set token
$response = $this->withToken('my-api-token')
    ->getJson('/api/users');

// Bearer token (same as withToken)
$response = $this->withToken('my-token', 'Bearer')
    ->getJson('/api/users');

Cookies

<?php

$response = $this->withCookies([
    'theme' => 'dark',
    'locale' => 'ru',
])->get('/dashboard');

$response = $this->withCookie('session_id', 'abc123')
    ->get('/dashboard');

// Unencrypted cookies
$response = $this->withUnencryptedCookies([
    'name' => 'value',
])->get('/dashboard');

Assertions на ответ

Status Assertions

<?php

$response->assertStatus(200);
$response->assertOk();              // 200
$response->assertCreated();         // 201
$response->assertAccepted();        // 202
$response->assertNoContent();       // 204
$response->assertMovedPermanently(); // 301
$response->assertFound();           // 302
$response->assertNotModified();     // 304
$response->assertBadRequest();      // 400
$response->assertUnauthorized();    // 401
$response->assertPaymentRequired(); // 402
$response->assertForbidden();       // 403
$response->assertNotFound();        // 404
$response->assertMethodNotAllowed(); // 405
$response->assertConflict();        // 409
$response->assertGone();            // 410
$response->assertUnprocessable();   // 422
$response->assertTooManyRequests(); // 429
$response->assertServerError();     // 500
$response->assertServiceUnavailable(); // 503

JSON Assertions

<?php

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

// Assert exact JSON structure
$response->assertJson([
    'data' => [
        'id' => 1,
        'name' => 'John',
    ],
]);

// Assert JSON contains (subset matching)
$response->assertJson(fn (\Illuminate\Testing\Fluent\AssertableJson $json) =>
    $json->where('data.id', 1)
        ->where('data.name', 'John')
        ->missing('data.password')
        ->etc() // Allow additional properties
);

// Assert exact JSON match (strict)
$response->assertExactJson([
    'data' => [
        'id' => 1,
        'name' => 'John',
        'email' => '[email protected]',
    ],
]);

// Assert JSON path
$response->assertJsonPath('data.name', 'John');
$response->assertJsonPath('data.tags', fn ($tags) => count($tags) > 0);

// Assert JSON structure (keys exist, values can be anything)
$response->assertJsonStructure([
    'data' => [
        'id',
        'name',
        'email',
        'created_at',
    ],
]);

// Assert JSON count
$response->assertJsonCount(3, 'data'); // 3 items in data array

// Assert JSON fragment (subset anywhere in response)
$response->assertJsonFragment([
    'name' => 'John',
]);

// Assert JSON missing fragment
$response->assertJsonMissingExact([
    'name' => 'Jane',
]);

// Assert JSON is array
$response->assertJsonIsArray('data');

// Assert JSON is object
$response->assertJsonIsObject('data');

Fluent JSON Testing (AssertableJson)

<?php

$response->assertJson(fn (\Illuminate\Testing\Fluent\AssertableJson $json) =>
    $json->has('data')
        ->has('data.id')
        ->has('data.name')
        ->has('meta.total')
        ->missing('data.password')
        ->where('data.id', 1)
        ->where('data.name', fn ($name) => str_starts_with($name, 'J'))
        ->whereType('data.id', 'integer')
        ->whereType('data.name', 'string')
        ->whereNot('data.role', 'admin')
        ->etc()
);

// Testing collections
$response->assertJson(fn (\Illuminate\Testing\Fluent\AssertableJson $json) =>
    $json->has('data', 3) // 3 items in data
        ->has('data.0', fn ($json) => // First item
            $json->where('id', 1)
                ->where('name', 'John')
                ->etc()
        )
);

// First item shorthand
$response->assertJson(fn (\Illuminate\Testing\Fluent\AssertableJson $json) =>
    $json->has('data', 3, fn ($json) => // Assert count AND first item
        $json->where('name', 'John')
            ->etc()
    )
);

Подвох на экзамене: assertJson() выполняет ПОДМНОЖЕСТВО-совпадение (ответ может содержать дополнительные ключи). assertExactJson() требует ТОЧНОГО совпадения. При использовании fluent API, etc() нужен, если вы не проверяете все ключи.

View Assertions

<?php

$response = $this->get('/welcome');

// Assert response contains text
$response->assertSee('Welcome');
$response->assertSeeText('Welcome'); // Only visible text (no HTML tags)
$response->assertDontSee('Error');

// Assert with HTML escaping disabled
$response->assertSee('<strong>Bold</strong>', false); // false = don't escape

// Assert view name
$response->assertViewIs('welcome');

// Assert view has data
$response->assertViewHas('users');
$response->assertViewHas('users', function ($users) {
    return $users->count() === 3;
});
$response->assertViewHas('title', 'Welcome Page');
$response->assertViewHasAll([
    'users',
    'title' => 'Welcome Page',
]);
$response->assertViewMissing('admin_panel');

Redirect Assertions

<?php

$response = $this->post('/login', [
    'email' => '[email protected]',
    'password' => 'wrong',
]);

$response->assertRedirect('/login'); // Assert redirect to URL
$response->assertRedirectToRoute('login'); // Assert redirect to named route
$response->assertRedirectToSignedRoute('verify-email');

// Follow redirect
$response = $this->followingRedirects()
    ->post('/login', [
        'email' => '[email protected]',
        'password' => 'correct',
    ]);
$response->assertSee('Dashboard');

Header Assertions

<?php

$response->assertHeader('Content-Type', 'application/json');
$response->assertHeaderMissing('X-Custom-Header');

// Download assertions
$response->assertDownload(); // Assert response is download
$response->assertDownload('report.pdf'); // Assert specific filename

Session Assertions

<?php

$response->assertSessionHas('message');
$response->assertSessionHas('message', 'Welcome!');
$response->assertSessionHas('user', function ($value) {
    return $value->id === 1;
});
$response->assertSessionHasAll([
    'message' => 'Welcome!',
    'status' => 'success',
]);
$response->assertSessionMissing('error');
$response->assertSessionHasErrors(['email']);
$response->assertSessionHasErrors([
    'email' => 'The email field is required.',
]);
$response->assertSessionHasNoErrors();
$response->assertSessionHasInput('name', 'John');

Аутентификация в тестах

<?php

use App\Models\User;

// Act as a specific user
$user = User::factory()->create();

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

$response->assertOk();

// Act as user on specific guard
$admin = User::factory()->admin()->create();

$response = $this->actingAs($admin, 'admin')
    ->getJson('/admin/dashboard');

// Test unauthenticated access
$response = $this->getJson('/api/profile');
$response->assertUnauthorized(); // 401

// Sanctum authentication
$user = User::factory()->create();
$token = $user->createToken('test-token');

$response = $this->withToken($token->plainTextToken)
    ->getJson('/api/profile');

// Or simply:
$response = $this->actingAs($user, 'sanctum')
    ->getJson('/api/profile');

Подвох на экзамене: actingAs($user) использует guard по умолчанию (web). Для API с Sanctum используйте actingAs($user, 'sanctum'). Забытый guard -- частая ошибка.

Тестирование загрузки файлов

<?php

use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;

public function test_avatar_upload(): void
{
    Storage::fake('public'); // Fake storage disk

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

    $response = $this->actingAs($user)
        ->postJson('/api/profile/avatar', [
            'avatar' => UploadedFile::fake()->image('avatar.jpg', 200, 200),
        ]);

    $response->assertOk();

    // Assert file was stored
    Storage::disk('public')->assertExists('avatars/' . $user->id . '.jpg');

    // Assert file was NOT stored
    Storage::disk('public')->assertMissing('avatars/old-avatar.jpg');
}

public function test_document_upload(): void
{
    Storage::fake('local');

    $response = $this->actingAs(User::factory()->create())
        ->postJson('/api/documents', [
            'file' => UploadedFile::fake()->create('report.pdf', 1024), // 1MB
        ]);

    $response->assertCreated();
    Storage::disk('local')->assertExists('documents/report.pdf');
}

public function test_rejects_large_files(): void
{
    Storage::fake('public');

    $response = $this->actingAs(User::factory()->create())
        ->postJson('/api/documents', [
            'file' => UploadedFile::fake()->create('huge.pdf', 20000), // 20MB
        ]);

    $response->assertUnprocessable() // 422 validation error
        ->assertJsonValidationErrors('file');
}

Fake файлы

<?php

use Illuminate\Http\UploadedFile;

// Fake image with dimensions
$file = UploadedFile::fake()->image('photo.jpg', 640, 480);

// Fake image with specific size
$file = UploadedFile::fake()->image('photo.png')->size(500); // 500KB

// Fake file with specific mime type
$file = UploadedFile::fake()->create('document.pdf', 1024, 'application/pdf');

// Fake file with specific content
$file = UploadedFile::fake()->createWithContent(
    'data.csv',
    "name,email\nJohn,[email protected]"
);

Тестирование Validation

<?php

public function test_user_creation_validation(): void
{
    $response = $this->postJson('/api/users', []);

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

public function test_email_must_be_valid(): void
{
    $response = $this->postJson('/api/users', [
        'name' => 'John',
        'email' => 'not-an-email',
        'password' => 'password123',
    ]);

    $response->assertJsonValidationErrorFor('email');
}

public function test_email_must_be_unique(): void
{
    User::factory()->create(['email' => '[email protected]']);

    $response = $this->postJson('/api/users', [
        'name' => 'John',
        'email' => '[email protected]',
        'password' => 'password123',
    ]);

    $response->assertJsonValidationErrors([
        'email' => 'The email has already been taken.',
    ]);
}

// Assert NO validation errors
public function test_valid_data_passes(): void
{
    $response = $this->postJson('/api/users', [
        'name' => 'John',
        'email' => '[email protected]',
        'password' => 'SecurePass123!',
    ]);

    $response->assertJsonMissingValidationErrors(['name', 'email']);
}

Тестирование API CRUD (Pest)

<?php

use App\Models\User;
use App\Models\Post;

beforeEach(function () {
    $this->user = User::factory()->create();
});

test('can list all posts', function () {
    Post::factory(5)->create();

    $this->actingAs($this->user, 'sanctum')
        ->getJson('/api/posts')
        ->assertOk()
        ->assertJsonCount(5, 'data')
        ->assertJsonStructure([
            'data' => [
                '*' => ['id', 'title', 'body', 'created_at'],
            ],
            'meta' => ['total', 'per_page'],
        ]);
});

test('can create a post', function () {
    $data = [
        'title' => 'My First Post',
        'body' => 'This is the body of my first post.',
    ];

    $this->actingAs($this->user, 'sanctum')
        ->postJson('/api/posts', $data)
        ->assertCreated()
        ->assertJsonPath('data.title', 'My First Post');

    $this->assertDatabaseHas('posts', [
        'title' => 'My First Post',
        'user_id' => $this->user->id,
    ]);
});

test('can show a single post', function () {
    $post = Post::factory()->create();

    $this->actingAs($this->user, 'sanctum')
        ->getJson("/api/posts/{$post->id}")
        ->assertOk()
        ->assertJsonPath('data.id', $post->id);
});

test('can update a post', function () {
    $post = Post::factory()->for($this->user)->create();

    $this->actingAs($this->user, 'sanctum')
        ->putJson("/api/posts/{$post->id}", [
            'title' => 'Updated Title',
        ])
        ->assertOk()
        ->assertJsonPath('data.title', 'Updated Title');
});

test('can delete a post', function () {
    $post = Post::factory()->for($this->user)->create();

    $this->actingAs($this->user, 'sanctum')
        ->deleteJson("/api/posts/{$post->id}")
        ->assertNoContent();

    $this->assertDatabaseMissing('posts', ['id' => $post->id]);
});

test('cannot update someone else\'s post', function () {
    $otherUser = User::factory()->create();
    $post = Post::factory()->for($otherUser)->create();

    $this->actingAs($this->user, 'sanctum')
        ->putJson("/api/posts/{$post->id}", ['title' => 'Hacked'])
        ->assertForbidden();
});

Тестирование с middleware

<?php

// Disable all middleware
$response = $this->withoutMiddleware()
    ->getJson('/api/users');

// Disable specific middleware
$response = $this->withoutMiddleware(ThrottleRequests::class)
    ->getJson('/api/users');

// Keep middleware but test exceptions
$response = $this->withMiddleware()
    ->getJson('/api/admin')
    ->assertForbidden();

Подвох на экзамене: withoutMiddleware() без аргументов отключает ВСЕ middleware, включая авторизацию, CSRF и т.д. Для точного тестирования лучше отключать конкретное middleware.


Проверь себя

Какой guard нужно указать в actingAs() для тестирования Sanctum API?

Что произойдёт, если в assertJson() передать массив и ответ содержит дополнительные ключи?

Что делает метод followingRedirects()?

Как правильно тестировать загрузку файла в Laravel?

Чем отличается `$this->get('/api/users')` от `$this->getJson('/api/users')`?