Skip to content

Testing

HUMAE usa Pest 3 sobre PHPUnit. El backend tiene 159+ tests / 464+ assertions con ejecución en ~4 segundos (SQLite in-memory + bcrypt 4 rounds).

Filosofía

Features > Units. Costo similar en Pest gracias a RefreshDatabase + SQLite in-memory; el valor es mucho mayor: un feature test valida controller + middleware + validation + service + resource + DB, todo de una.

Excepción: state machines + helpers puros (sin DB) → unit tests rápidos.

Estructura

tests/
├── Pest.php          Config global: RefreshDatabase + TestCase para Feature/
├── TestCase.php      Base class (extiende Illuminate\Foundation\Testing\TestCase)
├── Feature/
│   ├── Api/V1/
│   │   ├── Auth/
│   │   ├── Companies/
│   │   ├── Directory/
│   │   ├── Interviews/
│   │   ├── Membership/
│   │   ├── Notifications/
│   │   ├── Pipeline/
│   │   ├── Profile/
│   │   ├── Psychometric/
│   │   ├── Reports/
│   │   └── HealthTest.php
│   ├── Services/     Tests directos de services (sin hit HTTP)
│   └── Support/      ApiResponseTest, etc.
└── Unit/
    └── Services/     State machines + helpers puros
        ├── VacancyStateMachineTest.php
        ├── AssignmentStageMachineTest.php
        └── InterviewStateMachineTest.php

Correr tests

bash
# Todo
composer test

# Cobertura
composer test:coverage

# Archivo específico
./vendor/bin/pest tests/Feature/Api/V1/Membership/CheckoutTest.php

# Con filter (regex)
./vendor/bin/pest --filter "idempotent"

# Grupo específico
./vendor/bin/pest --group=slow

# Watch mode
./vendor/bin/pest --watch

# Paralelo (cuidado con DB sharing)
./vendor/bin/pest --parallel

Config global

phpunit.xml

xml
<php>
    <env name="APP_ENV" value="testing"/>
    <env name="DB_CONNECTION" value="sqlite"/>
    <env name="DB_DATABASE" value=":memory:"/>
    <env name="BCRYPT_ROUNDS" value="4"/>        <!-- acelera tests -->
    <env name="CACHE_STORE" value="array"/>
    <env name="SESSION_DRIVER" value="array"/>
    <env name="QUEUE_CONNECTION" value="sync"/>
    <env name="MAIL_MAILER" value="array"/>
    <env name="TELESCOPE_ENABLED" value="false"/>
</php>

tests/Pest.php

php
pest()->extend(TestCase::class)
    ->use(RefreshDatabase::class)
    ->in('Feature');

Solo los tests en tests/Feature/ obtienen RefreshDatabase. Los unit tests en tests/Unit/ NO tienen Laravel booteado — son más rápidos (~0.01s cada uno).

Patrón feature test

Plantilla base

php
<?php

declare(strict_types=1);

use App\Enums\UserRole;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Laravel\Sanctum\Sanctum;

beforeEach(function (): void {
    $this->seed(RolesAndPermissionsSeeder::class);
});

it('requires authentication', function (): void {
    $response = $this->getJson('/api/v1/me/profile');
    $response->assertStatus(401);
});

it('allows a candidate to see their profile', function (): void {
    $user = User::factory()->create();
    $user->assignRole(UserRole::Candidate->value);
    Sanctum::actingAs($user);

    $response = $this->getJson('/api/v1/me/profile');

    $response->assertStatus(200)
        ->assertJsonPath('success', true)
        ->assertJsonPath('data.id', $user->id);
});

it('rejects a recruiter trying to read /me/profile (wrong role)', function (): void {
    $user = User::factory()->create();
    $user->assignRole(UserRole::Recruiter->value);
    Sanctum::actingAs($user);

    $response = $this->getJson('/api/v1/me/profile');
    $response->assertStatus(403);
});

Assertions útiles

php
$response->assertStatus(201);
$response->assertOk();  // 200
$response->assertUnauthorized();  // 401
$response->assertForbidden();     // 403
$response->assertNotFound();      // 404
$response->assertUnprocessable(); // 422

$response->assertJsonStructure([
    'success',
    'data' => [
        'id', 'email', 'roles'
    ]
]);

$response->assertJsonPath('data.email', 'test@humae.com');
$response->assertJsonFragment(['role' => 'candidate']);
$response->assertJsonCount(3, 'data.items');

$response->assertHeader('X-RateLimit-Limit', '120');
$response->assertCookie('humae_session');

DB assertions

php
$this->assertDatabaseHas('users', [
    'email' => 'test@humae.com',
    'status' => 'active',
]);

$this->assertDatabaseMissing('memberships', [
    'user_id' => $user->id,
    'status' => 'active',
]);

$this->assertDatabaseCount('vacancies', 5);

Patrón test de state machine (unit)

php
<?php

declare(strict_types=1);

use App\Enums\VacancyState;
use App\Services\VacancyStateMachine;

it('allows borrador → activa', function (): void {
    expect(VacancyStateMachine::canTransition(
        VacancyState::Borrador,
        VacancyState::Activa,
    ))->toBeTrue();
});

it('does not allow skipping states', function (): void {
    expect(VacancyStateMachine::canTransition(
        VacancyState::Borrador,
        VacancyState::Cubierta,
    ))->toBeFalse();
});

it('does not allow exits from terminal states', function (): void {
    expect(VacancyStateMachine::allowedFrom(VacancyState::Cubierta))->toBe([]);
    expect(VacancyStateMachine::allowedFrom(VacancyState::Cancelada))->toBe([]);
});

Patrón test de service (con DB)

php
<?php

declare(strict_types=1);

use App\Enums\MembershipStatus;
use App\Models\{User, Membership, MembershipPlan};
use App\Services\MembershipService;

beforeEach(function (): void {
    $this->plan = MembershipPlan::factory()->create(['duration_days' => 180]);
    $this->service = app(MembershipService::class);
});

it('expireStale() expires multiple stale memberships in one call', function (): void {
    foreach (range(1, 3) as $i) {
        Membership::factory()->create([
            'user_id' => User::factory()->create()->id,
            'membership_plan_id' => $this->plan->id,
            'status' => MembershipStatus::Active,
            'expires_at' => now()->subDays($i),
        ]);
    }

    expect($this->service->expireStale())->toBe(3);
    expect(Membership::where('status', MembershipStatus::Expired->value)->count())
        ->toBe(3);
});

Mocking

Stripe (evita hit real)

php
use App\Helpers\StripeClient;
use Stripe\Checkout\Session;

beforeEach(function (): void {
    $fakeClient = Mockery::mock(StripeClient::class);
    $fakeClient->shouldReceive('createCheckoutSession')
        ->andReturn(Session::constructFrom([
            'id' => 'cs_test_fake',
            'url' => 'https://checkout.stripe.com/c/fake',
            'customer' => 'cus_test_fake',
        ]));

    $this->app->instance(StripeClient::class, $fakeClient);
});

Notifications

php
use Illuminate\Support\Facades\Notification;
use App\Notifications\MembershipActivatedNotification;

it('sends membership activation email', function (): void {
    Notification::fake();

    // ... disparar el flujo ...

    Notification::assertSentTo(
        $user,
        MembershipActivatedNotification::class
    );
});

Queue jobs

php
use Illuminate\Support\Facades\Queue;
use App\Jobs\ExpireMembershipsJob;

it('dispatches expire job', function (): void {
    Queue::fake();

    // ... trigger ...

    Queue::assertPushed(ExpireMembershipsJob::class);
});

HTTP requests externos

php
use Illuminate\Support\Facades\Http;

it('returns fake response', function (): void {
    Http::fake([
        'api.stripe.com/*' => Http::response(['ok' => true], 200),
    ]);

    // ...
});

Clock (carbon)

php
use Illuminate\Support\Carbon;

it('expires in 180 days', function (): void {
    Carbon::setTestNow('2026-01-01 10:00:00');

    $membership = $this->service->createCheckout(...);

    expect($membership->expires_at->toDateString())->toBe('2026-06-30');
});

Factory helpers

Las factories se componen:

php
// Crear un candidato completo con todo
$candidate = CandidateProfile::factory()
    ->active()
    ->withMembership()
    ->has(CandidateExperience::factory()->count(3))
    ->has(CandidateLanguage::factory()->count(2))
    ->create();

// Crear el usuario correcto para role
$user = User::factory()->create();
$user->assignRole('recruiter');
$user->markEmailAsVerified();

// Sanctum auth
Sanctum::actingAs($user);

Helpers custom

En tests/Pest.php o un archivo compañero, define helpers:

php
function makeActiveCandidate(): CandidateProfile
{
    $user = User::factory()->create();
    $user->assignRole(UserRole::Candidate->value);

    Membership::factory()->create([
        'user_id' => $user->id,
        'status' => MembershipStatus::Active,
        'expires_at' => now()->addDays(30),
    ]);

    return CandidateProfile::factory()->create([
        'user_id' => $user->id,
        'state' => CandidateState::Activo->value,
    ]);
}

// Uso
it('appears in directory', function (): void {
    $candidate = makeActiveCandidate();
    // ...
});

Colisión de nombres

Si defines makeActiveCandidate() en dos archivos distintos, PHP lanza "cannot redeclare". Soluciones:

  • Usar nombres namespaced: directoryMakeActiveCandidate()
  • Mover a una clase helper: TestData::activeCandidate()

Datasets (data providers)

php
it('validates vacancy state transitions', function (VacancyState $from, VacancyState $to, bool $allowed) {
    expect(VacancyStateMachine::canTransition($from, $to))->toBe($allowed);
})->with([
    'borrador → activa' => [VacancyState::Borrador, VacancyState::Activa, true],
    'borrador → cubierta' => [VacancyState::Borrador, VacancyState::Cubierta, false],
    'activa → en_busqueda' => [VacancyState::Activa, VacancyState::EnBusqueda, true],
    'cubierta → cualquiera' => [VacancyState::Cubierta, VacancyState::Activa, false],
]);

Cada entry corre como un test independiente.

Performance de la suite

Objetivos:

  • Total suite < 10 segundos en local (actualmente ~4s).
  • Un feature test < 100ms promedio.
  • Un unit test < 10ms.

Optimizaciones aplicadas

  • SQLite in-memory (no toca disco).
  • BCrypt rounds 4 en tests (vs 12 en prod).
  • RefreshDatabase usa transaction por test (rollback rápido).
  • MAIL_MAILER=array no envía correos reales.
  • QUEUE_CONNECTION=sync ejecuta jobs sincrono.

Cuando agregar $this->withoutExceptionHandling()

Por default Laravel atrapa excepciones y las convierte a 500. Si quieres ver la excepción real:

php
it('works', function () {
    $this->withoutExceptionHandling();
    // Ahora la excepción propaga al test
});

Útil para debugging, no para prod tests.

Cobertura de código

bash
# Requiere Xdebug o pcov instalado
composer test:coverage

# HTML report en storage/coverage/

Objetivo: ≥ 70% cobertura global, ≥ 90% en services críticos (MembershipService, InterviewService).

CI

GitHub Actions workflow en .github/workflows/backend.yml:

yaml
- name: Pest tests
  run: composer test

Se corre automáticamente en push a main y en PRs.

Siguiente

Cómo diagnosticar cuando algo falla en prod: Troubleshooting →

Manual de usuario HUMAE · Uso interno