@cristiancorreau/forge 3.1.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +265 -109
- package/assets/adapters/claude-code/commands/laravel-eloquent.md +7 -0
- package/assets/adapters/claude-code/commands/laravel-mcp.md +7 -0
- package/assets/adapters/claude-code/commands/laravel-pest.md +7 -0
- package/assets/adapters/claude-code/commands/laravel-security.md +7 -0
- package/assets/adapters/claude-code/commands/laravel-verify.md +7 -0
- package/assets/core/hooks/pre-bash-check.js +46 -0
- package/assets/core/hooks/pre-edit-check.js +14 -0
- package/assets/core/skills/laravel-eloquent/SKILL.md +453 -0
- package/assets/core/skills/laravel-mcp/SKILL.md +468 -0
- package/assets/core/skills/laravel-pest/SKILL.md +686 -0
- package/assets/core/skills/laravel-security/SKILL.md +658 -0
- package/assets/core/skills/laravel-verify/SKILL.md +462 -0
- package/assets/manifest.json +27 -2
- package/assets/profiles/astro/agents/frontend-engineer.md +2 -0
- package/assets/profiles/django/agents/api-engineer.md +2 -0
- package/assets/profiles/expo/agents/mobile-engineer.md +2 -0
- package/assets/profiles/express/agents/api-engineer.md +2 -0
- package/assets/profiles/fastapi/agents/api-engineer.md +2 -0
- package/assets/profiles/flask/agents/api-engineer.md +2 -0
- package/assets/profiles/flutter/agents/mobile-engineer.md +12 -10
- package/assets/profiles/go-gin/agents/api-engineer.md +3 -1
- package/assets/profiles/hono-drizzle/agents/api-engineer.md +2 -0
- package/assets/profiles/laravel/README.md +16 -2
- package/assets/profiles/laravel/agents/api-engineer.md +2 -0
- package/assets/profiles/laravel/agents/fullstack-engineer.md +4 -2
- package/assets/profiles/laravel/agents/laravel-specialist.md +607 -0
- package/assets/profiles/laravel/agents/laravel-test-engineer.md +448 -0
- package/assets/profiles/nestjs/agents/api-engineer.md +3 -1
- package/assets/profiles/nextjs-admin/agents/admin-engineer.md +2 -0
- package/assets/profiles/playwright-crawler/agents/scanner-engineer.md +2 -0
- package/assets/profiles/rails/agents/fullstack-engineer.md +2 -0
- package/assets/profiles/rust/agents/api-engineer.md +2 -0
- package/assets/profiles/springboot/agents/api-engineer.md +11 -9
- package/assets/profiles/sveltekit/agents/frontend-engineer.md +4 -2
- package/assets/profiles/vuenuxt/agents/frontend-engineer.md +12 -10
- package/assets/profiles/wordpress/agents/divi-engineer.md +2 -0
- package/assets/profiles/wordpress/agents/elementor-engineer.md +2 -0
- package/dist/cli.js +10 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/add.d.ts +2 -0
- package/dist/commands/add.d.ts.map +1 -0
- package/dist/commands/add.js +187 -0
- package/dist/commands/add.js.map +1 -0
- package/dist/commands/mcp.d.ts +42 -0
- package/dist/commands/mcp.d.ts.map +1 -0
- package/dist/commands/mcp.js +141 -0
- package/dist/commands/mcp.js.map +1 -0
- package/dist/lib/catalog.d.ts.map +1 -1
- package/dist/lib/catalog.js +5 -0
- package/dist/lib/catalog.js.map +1 -1
- package/dist/lib/mcp-tools.d.ts +37 -0
- package/dist/lib/mcp-tools.d.ts.map +1 -0
- package/dist/lib/mcp-tools.js +124 -0
- package/dist/lib/mcp-tools.js.map +1 -0
- package/dist/lib/skill-security.d.ts +66 -0
- package/dist/lib/skill-security.d.ts.map +1 -0
- package/dist/lib/skill-security.js +225 -0
- package/dist/lib/skill-security.js.map +1 -0
- package/dist/lib/skill-source.d.ts +29 -0
- package/dist/lib/skill-source.d.ts.map +1 -0
- package/dist/lib/skill-source.js +94 -0
- package/dist/lib/skill-source.js.map +1 -0
- package/dist/tui/dashboard.d.ts.map +1 -1
- package/dist/tui/dashboard.js +3 -6
- package/dist/tui/dashboard.js.map +1 -1
- package/dist/tui/panel.d.ts.map +1 -1
- package/dist/tui/panel.js +3 -6
- package/dist/tui/panel.js.map +1 -1
- package/dist/tui/wizard.d.ts.map +1 -1
- package/dist/tui/wizard.js +3 -13
- package/dist/tui/wizard.js.map +1 -1
- package/dist/ui/colors.d.ts +3 -1
- package/dist/ui/colors.d.ts.map +1 -1
- package/dist/ui/colors.js +11 -2
- package/dist/ui/colors.js.map +1 -1
- package/dist/ui/header.d.ts.map +1 -1
- package/dist/ui/header.js +4 -3
- package/dist/ui/header.js.map +1 -1
- package/dist/ui/theme.d.ts +24 -0
- package/dist/ui/theme.d.ts.map +1 -0
- package/dist/ui/theme.js +32 -0
- package/dist/ui/theme.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
# Skill: laravel-pest
|
|
2
|
+
|
|
3
|
+
TDD con Pest 3 en Laravel: estructura `tests/Feature` y `tests/Unit`, `RefreshDatabase`,
|
|
4
|
+
factories y states, datasets, expectations, HTTP tests, fakes (`Mail`, `Queue`, `Event`,
|
|
5
|
+
`Http`, `Storage`), time travel y coverage con umbral mínimo. Activar al escribir o correr
|
|
6
|
+
tests en un proyecto Laravel.
|
|
7
|
+
|
|
8
|
+
Triggers: /laravel-pest, "escribir tests", "test con pest", "TDD en laravel",
|
|
9
|
+
"feature test", "model factory", "mockear cola/mail/evento", "fake de http",
|
|
10
|
+
"coverage de tests", "correr los tests".
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Cuándo usar este skill
|
|
15
|
+
|
|
16
|
+
- Al implementar una feature con TDD: el test se escribe junto con (o antes de) el código.
|
|
17
|
+
- Al crear o ajustar factories y states para preparar datos de prueba.
|
|
18
|
+
- Al testear endpoints HTTP (status, JSON, redirects, validación).
|
|
19
|
+
- Al aislar efectos secundarios con fakes (mail, queue, event, http, storage).
|
|
20
|
+
- Al verificar lógica dependiente del tiempo con time travel.
|
|
21
|
+
- Al medir cobertura y exigir un umbral mínimo en CI.
|
|
22
|
+
|
|
23
|
+
> Las versiones recientes de Laravel instalan Pest 3 por defecto; verifica el runner
|
|
24
|
+
> declarado en `composer.json`. Si el proyecto todavía usa PHPUnit puro, cada
|
|
25
|
+
> sección incluye la equivalencia. Pest corre sobre PHPUnit, así que ambos estilos
|
|
26
|
+
> conviven en el mismo `tests/` sin conflicto.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Estructura de tests
|
|
31
|
+
|
|
32
|
+
Laravel ubica los tests en dos carpetas, configuradas como suites en `phpunit.xml`:
|
|
33
|
+
|
|
34
|
+
- `tests/Feature/` — ejercitan el framework completo (rutas, middleware, DB, container).
|
|
35
|
+
- `tests/Unit/` — clases aisladas sin booteo de Laravel; rápidos, sin acceso a DB.
|
|
36
|
+
|
|
37
|
+
El bootstrap de Pest vive en `tests/Pest.php`. Ahí se aplican traits y helpers por
|
|
38
|
+
carpeta con `uses()`, evitando repetirlos en cada archivo:
|
|
39
|
+
|
|
40
|
+
```php
|
|
41
|
+
// tests/Pest.php
|
|
42
|
+
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
43
|
+
use Tests\TestCase;
|
|
44
|
+
|
|
45
|
+
// Todos los Feature tests heredan TestCase y refrescan la DB en cada test.
|
|
46
|
+
pest()->extend(TestCase::class)
|
|
47
|
+
->use(RefreshDatabase::class)
|
|
48
|
+
->in('Feature');
|
|
49
|
+
|
|
50
|
+
// Los Unit tests solo necesitan el TestCase base de PHPUnit (sin Laravel).
|
|
51
|
+
pest()->extend(PHPUnit\Framework\TestCase::class)
|
|
52
|
+
->in('Unit');
|
|
53
|
+
|
|
54
|
+
// Expectation custom reutilizable en toda la suite.
|
|
55
|
+
expect()->extend('toBeOne', fn () => $this->toBe(1));
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Generar tests con artisan (las versiones recientes de Laravel scaffoldean en formato Pest por defecto):
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
php artisan make:test ProductPurchaseTest --pest # tests/Feature (default)
|
|
62
|
+
php artisan make:test PricingTest --pest --unit # tests/Unit
|
|
63
|
+
php artisan make:test LegacyTest --phpunit # forzar clase PHPUnit
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Un Feature test mínimo en Pest:
|
|
67
|
+
|
|
68
|
+
```php
|
|
69
|
+
<?php
|
|
70
|
+
// tests/Feature/HomeTest.php
|
|
71
|
+
|
|
72
|
+
it('muestra la home', function () {
|
|
73
|
+
$response = $this->get('/');
|
|
74
|
+
|
|
75
|
+
$response->assertOk();
|
|
76
|
+
});
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Equivalencia PHPUnit:
|
|
80
|
+
|
|
81
|
+
```php
|
|
82
|
+
<?php
|
|
83
|
+
// tests/Feature/HomeTest.php
|
|
84
|
+
namespace Tests\Feature;
|
|
85
|
+
|
|
86
|
+
use Tests\TestCase;
|
|
87
|
+
|
|
88
|
+
class HomeTest extends TestCase
|
|
89
|
+
{
|
|
90
|
+
public function test_muestra_la_home(): void
|
|
91
|
+
{
|
|
92
|
+
$this->get('/')->assertOk();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## RefreshDatabase y base de datos
|
|
100
|
+
|
|
101
|
+
`RefreshDatabase` envuelve cada test en una transacción y la revierte al terminar, dejando
|
|
102
|
+
el schema migrado una sola vez. Es el trait correcto para la mayoría de los Feature tests.
|
|
103
|
+
|
|
104
|
+
```php
|
|
105
|
+
<?php
|
|
106
|
+
// tests/Feature/OrderTest.php
|
|
107
|
+
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
108
|
+
|
|
109
|
+
uses(RefreshDatabase::class); // omitir si ya está aplicado en tests/Pest.php
|
|
110
|
+
|
|
111
|
+
it('persiste una orden', function () {
|
|
112
|
+
$this->assertDatabaseCount('orders', 0);
|
|
113
|
+
|
|
114
|
+
Order::create(['total' => 1000, 'status' => 'pending']);
|
|
115
|
+
|
|
116
|
+
$this->assertDatabaseHas('orders', ['total' => 1000, 'status' => 'pending']);
|
|
117
|
+
$this->assertDatabaseCount('orders', 1);
|
|
118
|
+
});
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Traits relacionados, según el caso:
|
|
122
|
+
|
|
123
|
+
- `DatabaseTransactions` — envuelve en transacción pero NO migra; útil si la DB ya tiene schema.
|
|
124
|
+
- `DatabaseMigrations` — corre `migrate:fresh` antes de cada test (más lento; rara vez necesario).
|
|
125
|
+
- `RefreshDatabase` — lo recomendado: migra una vez, transacciona por test.
|
|
126
|
+
|
|
127
|
+
Aserciones de DB disponibles en cualquier test:
|
|
128
|
+
|
|
129
|
+
```php
|
|
130
|
+
$this->assertDatabaseHas('users', ['email' => 'a@b.com']);
|
|
131
|
+
$this->assertDatabaseMissing('users', ['email' => 'baneado@b.com']);
|
|
132
|
+
$this->assertDatabaseCount('users', 3);
|
|
133
|
+
$this->assertModelExists($user);
|
|
134
|
+
$this->assertModelMissing($user); // tras delete()
|
|
135
|
+
$this->assertSoftDeleted($user); // con SoftDeletes
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Para tests veloces conviene una conexión SQLite en memoria en `phpunit.xml`:
|
|
139
|
+
|
|
140
|
+
```xml
|
|
141
|
+
<php>
|
|
142
|
+
<env name="DB_CONNECTION" value="sqlite"/>
|
|
143
|
+
<env name="DB_DATABASE" value=":memory:"/>
|
|
144
|
+
</php>
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
> Si el código bajo prueba usa features específicas de PostgreSQL (pgvector,
|
|
148
|
+
> `whereVectorSimilarTo`, JSON operators), NO uses SQLite en memoria: apunta a una DB
|
|
149
|
+
> de test Postgres real, porque SQLite no soporta esa sintaxis.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Model factories y states
|
|
154
|
+
|
|
155
|
+
Las factories generan datos de prueba realistas. Se generan con artisan y viven en
|
|
156
|
+
`database/factories/`:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
php artisan make:factory ProductFactory
|
|
160
|
+
php artisan make:model Product -f # modelo + factory de una vez
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Una factory en Laravel declara `definition()` y opcionalmente `states` como métodos
|
|
164
|
+
que devuelven `$this->state(...)`:
|
|
165
|
+
|
|
166
|
+
```php
|
|
167
|
+
<?php
|
|
168
|
+
// database/factories/ProductFactory.php
|
|
169
|
+
namespace Database\Factories;
|
|
170
|
+
|
|
171
|
+
use Illuminate\Database\Eloquent\Factories\Factory;
|
|
172
|
+
|
|
173
|
+
class ProductFactory extends Factory
|
|
174
|
+
{
|
|
175
|
+
public function definition(): array
|
|
176
|
+
{
|
|
177
|
+
return [
|
|
178
|
+
'name' => fake()->words(3, true),
|
|
179
|
+
'price' => fake()->numberBetween(100, 50_000),
|
|
180
|
+
'stock' => fake()->numberBetween(0, 100),
|
|
181
|
+
'is_active' => true,
|
|
182
|
+
'published_at' => now(),
|
|
183
|
+
];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// State: producto agotado.
|
|
187
|
+
public function outOfStock(): static
|
|
188
|
+
{
|
|
189
|
+
return $this->state(fn (array $attributes) => ['stock' => 0]);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// State: borrador (no publicado).
|
|
193
|
+
public function draft(): static
|
|
194
|
+
{
|
|
195
|
+
return $this->state(fn (array $attributes) => [
|
|
196
|
+
'is_active' => false,
|
|
197
|
+
'published_at' => null,
|
|
198
|
+
]);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Usar la factory en los tests:
|
|
204
|
+
|
|
205
|
+
```php
|
|
206
|
+
$product = Product::factory()->create(); // uno persistido
|
|
207
|
+
$product = Product::factory()->make(); // uno sin persistir
|
|
208
|
+
$products = Product::factory()->count(5)->create(); // colección
|
|
209
|
+
|
|
210
|
+
// States encadenados:
|
|
211
|
+
$agotado = Product::factory()->outOfStock()->create();
|
|
212
|
+
$borrador = Product::factory()->draft()->outOfStock()->create();
|
|
213
|
+
|
|
214
|
+
// Sobrescribir atributos puntuales:
|
|
215
|
+
$caro = Product::factory()->create(['price' => 99_000]);
|
|
216
|
+
|
|
217
|
+
// Sequences para variar valores entre instancias:
|
|
218
|
+
use Illuminate\Database\Eloquent\Factories\Sequence;
|
|
219
|
+
|
|
220
|
+
Product::factory()
|
|
221
|
+
->count(4)
|
|
222
|
+
->state(new Sequence(['is_active' => true], ['is_active' => false]))
|
|
223
|
+
->create();
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Relaciones con factories:
|
|
227
|
+
|
|
228
|
+
```php
|
|
229
|
+
// hasMany: una orden con 3 ítems.
|
|
230
|
+
$order = Order::factory()
|
|
231
|
+
->has(OrderItem::factory()->count(3))
|
|
232
|
+
->create();
|
|
233
|
+
|
|
234
|
+
// Atajo por nombre de relación + magic method:
|
|
235
|
+
$order = Order::factory()
|
|
236
|
+
->hasItems(3, ['quantity' => 2])
|
|
237
|
+
->create();
|
|
238
|
+
|
|
239
|
+
// belongsTo: ítems que pertenecen a una orden existente.
|
|
240
|
+
OrderItem::factory()->count(3)->for($order)->create();
|
|
241
|
+
|
|
242
|
+
// Pivot (belongsToMany) con datos de la tabla intermedia:
|
|
243
|
+
$user = User::factory()
|
|
244
|
+
->hasAttached(Role::factory()->count(2), ['assigned_at' => now()])
|
|
245
|
+
->create();
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
El modelo debe usar el trait `HasFactory` (incluido por defecto en los modelos de Laravel):
|
|
249
|
+
|
|
250
|
+
```php
|
|
251
|
+
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
252
|
+
|
|
253
|
+
class Product extends Model
|
|
254
|
+
{
|
|
255
|
+
use HasFactory;
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## Datasets
|
|
262
|
+
|
|
263
|
+
Los datasets ejecutan el mismo test con múltiples entradas, generando un caso por fila.
|
|
264
|
+
Reemplazan a `@dataProvider` de PHPUnit con menos boilerplate.
|
|
265
|
+
|
|
266
|
+
```php
|
|
267
|
+
it('valida emails', function (string $email, bool $valido) {
|
|
268
|
+
expect(filter_var($email, FILTER_VALIDATE_EMAIL) !== false)->toBe($valido);
|
|
269
|
+
})->with([
|
|
270
|
+
['a@b.com', true],
|
|
271
|
+
['sin-arroba', false],
|
|
272
|
+
['x@y.cl', true],
|
|
273
|
+
]);
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Con claves nombradas (mejoran la salida del runner):
|
|
277
|
+
|
|
278
|
+
```php
|
|
279
|
+
it('calcula descuento por tier', function (string $tier, int $esperado) {
|
|
280
|
+
expect(discountFor($tier))->toBe($esperado);
|
|
281
|
+
})->with([
|
|
282
|
+
'free' => ['free', 0],
|
|
283
|
+
'pro' => ['pro', 10],
|
|
284
|
+
'premium' => ['premium', 25],
|
|
285
|
+
]);
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Datasets reutilizables registrados en `tests/Pest.php`:
|
|
289
|
+
|
|
290
|
+
```php
|
|
291
|
+
// tests/Pest.php
|
|
292
|
+
dataset('emails_invalidos', ['sin-arroba', 'a@', '@b.com', '']);
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
```php
|
|
296
|
+
it('rechaza emails inválidos en el registro', function (string $email) {
|
|
297
|
+
$this->post('/register', ['email' => $email, 'password' => 'secret123'])
|
|
298
|
+
->assertSessionHasErrors('email');
|
|
299
|
+
})->with('emails_invalidos');
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Datasets que devuelven modelos (lazy, se evalúan dentro del test, no al colectar):
|
|
303
|
+
|
|
304
|
+
```php
|
|
305
|
+
it('no deja comprar productos inactivos', function (Closure $product) {
|
|
306
|
+
$this->actingAs(User::factory()->create())
|
|
307
|
+
->post('/cart', ['product_id' => $product()->id])
|
|
308
|
+
->assertForbidden();
|
|
309
|
+
})->with([
|
|
310
|
+
fn () => Product::factory()->draft()->create(),
|
|
311
|
+
fn () => Product::factory()->outOfStock()->create(),
|
|
312
|
+
]);
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Equivalencia PHPUnit (data provider):
|
|
316
|
+
|
|
317
|
+
```php
|
|
318
|
+
public static function emailProvider(): array
|
|
319
|
+
{
|
|
320
|
+
return [['a@b.com', true], ['sin-arroba', false]];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
#[\PHPUnit\Framework\Attributes\DataProvider('emailProvider')]
|
|
324
|
+
public function test_valida_emails(string $email, bool $valido): void
|
|
325
|
+
{
|
|
326
|
+
$this->assertSame($valido, filter_var($email, FILTER_VALIDATE_EMAIL) !== false);
|
|
327
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
---
|
|
331
|
+
|
|
332
|
+
## Expectations
|
|
333
|
+
|
|
334
|
+
La API de expectations de Pest es encadenable y legible. Equivale a los `assert*` de PHPUnit.
|
|
335
|
+
|
|
336
|
+
```php
|
|
337
|
+
expect($user->name)->toBe('Ada');
|
|
338
|
+
expect($total)->toBeInt()->toBeGreaterThan(0);
|
|
339
|
+
expect($collection)->toHaveCount(3);
|
|
340
|
+
expect($product->is_active)->toBeTrue();
|
|
341
|
+
expect($response->json())->toBeArray()->toHaveKey('data');
|
|
342
|
+
expect($order->status)->toBeIn(['pending', 'paid', 'shipped']);
|
|
343
|
+
expect($email)->toMatch('/@/');
|
|
344
|
+
expect(fn () => throw new RuntimeException('boom'))->toThrow(RuntimeException::class, 'boom');
|
|
345
|
+
|
|
346
|
+
// Negación e inversión:
|
|
347
|
+
expect($user->email)->not->toBeEmpty();
|
|
348
|
+
|
|
349
|
+
// Encadenar sobre la misma colección con and():
|
|
350
|
+
expect($product)
|
|
351
|
+
->name->toBe('Teclado')
|
|
352
|
+
->and($product->price)->toBeGreaterThan(0)
|
|
353
|
+
->and($product->stock)->toBeInt();
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
Tabla de equivalencias frecuentes:
|
|
357
|
+
|
|
358
|
+
| PHPUnit | Pest |
|
|
359
|
+
|----------------------------------|---------------------------------------|
|
|
360
|
+
| `assertSame($a, $b)` | `expect($b)->toBe($a)` |
|
|
361
|
+
| `assertEquals($a, $b)` | `expect($b)->toEqual($a)` |
|
|
362
|
+
| `assertTrue($x)` | `expect($x)->toBeTrue()` |
|
|
363
|
+
| `assertNull($x)` | `expect($x)->toBeNull()` |
|
|
364
|
+
| `assertCount(3, $c)` | `expect($c)->toHaveCount(3)` |
|
|
365
|
+
| `assertInstanceOf(X::class, $y)` | `expect($y)->toBeInstanceOf(X::class)`|
|
|
366
|
+
| `expectException(E::class)` | `...->toThrow(E::class)` |
|
|
367
|
+
|
|
368
|
+
> Dentro de un test Pest, `$this` es la instancia `TestCase`, así que las aserciones de
|
|
369
|
+
> Laravel (`$this->assertDatabaseHas`, `$this->get`) conviven con `expect()` sin problema.
|
|
370
|
+
|
|
371
|
+
---
|
|
372
|
+
|
|
373
|
+
## HTTP tests
|
|
374
|
+
|
|
375
|
+
Los Feature tests ejercitan rutas reales pasando por middleware y container. Recuerda que
|
|
376
|
+
en las versiones recientes de Laravel el middleware (incluido el de CSRF, `Illuminate\Foundation\Http\Middleware\ValidateCsrfToken`)
|
|
377
|
+
se configura en `bootstrap/app.php` — la estructura slim ya no expone `app/Http/Kernel.php`;
|
|
378
|
+
verifica leyendo `bootstrap/app.php`.
|
|
379
|
+
|
|
380
|
+
```php
|
|
381
|
+
it('crea un producto autenticado', function () {
|
|
382
|
+
$admin = User::factory()->create(['is_admin' => true]);
|
|
383
|
+
|
|
384
|
+
$response = $this->actingAs($admin)->postJson('/api/products', [
|
|
385
|
+
'name' => 'Teclado mecánico',
|
|
386
|
+
'price' => 45_000,
|
|
387
|
+
]);
|
|
388
|
+
|
|
389
|
+
$response
|
|
390
|
+
->assertCreated() // 201
|
|
391
|
+
->assertJsonPath('data.name', 'Teclado mecánico')
|
|
392
|
+
->assertJsonStructure(['data' => ['id', 'name', 'price']]);
|
|
393
|
+
|
|
394
|
+
$this->assertDatabaseHas('products', ['name' => 'Teclado mecánico']);
|
|
395
|
+
});
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
Verbos y aserciones HTTP más usados:
|
|
399
|
+
|
|
400
|
+
```php
|
|
401
|
+
$this->get('/dashboard')->assertOk(); // 200
|
|
402
|
+
$this->get('/admin')->assertForbidden(); // 403
|
|
403
|
+
$this->get('/no-existe')->assertNotFound(); // 404
|
|
404
|
+
$this->post('/login', [...])->assertRedirect('/home'); // 302 a destino
|
|
405
|
+
$this->getJson('/api/users')->assertStatus(200);
|
|
406
|
+
|
|
407
|
+
// JSON:
|
|
408
|
+
$this->getJson('/api/products/1')
|
|
409
|
+
->assertJson(['data' => ['id' => 1]]) // subconjunto
|
|
410
|
+
->assertJsonCount(10, 'data') // cantidad en una clave
|
|
411
|
+
->assertJsonPath('data.0.name', 'Mouse')
|
|
412
|
+
->assertJsonFragment(['status' => 'active'])
|
|
413
|
+
->assertJsonMissing(['password' => '*']);
|
|
414
|
+
|
|
415
|
+
// Validación fallida (422 en API, errores de sesión en web):
|
|
416
|
+
$this->postJson('/api/products', [])
|
|
417
|
+
->assertStatus(422)
|
|
418
|
+
->assertJsonValidationErrors(['name', 'price']);
|
|
419
|
+
|
|
420
|
+
$this->post('/products', [])
|
|
421
|
+
->assertSessionHasErrors(['name']);
|
|
422
|
+
|
|
423
|
+
// Headers, cookies, auth:
|
|
424
|
+
$this->withHeaders(['X-Trace' => 'abc'])->getJson('/api/ping');
|
|
425
|
+
$this->assertAuthenticated();
|
|
426
|
+
$this->assertGuest();
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
Autenticación en API token-based (Sanctum, el default de Laravel para SPA/mobile):
|
|
430
|
+
|
|
431
|
+
```php
|
|
432
|
+
use Laravel\Sanctum\Sanctum;
|
|
433
|
+
|
|
434
|
+
it('lista órdenes del usuario autenticado', function () {
|
|
435
|
+
$user = User::factory()->has(Order::factory()->count(2))->create();
|
|
436
|
+
|
|
437
|
+
Sanctum::actingAs($user, ['orders:read']);
|
|
438
|
+
|
|
439
|
+
$this->getJson('/api/orders')
|
|
440
|
+
->assertOk()
|
|
441
|
+
->assertJsonCount(2, 'data');
|
|
442
|
+
});
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
---
|
|
446
|
+
|
|
447
|
+
## Fakes
|
|
448
|
+
|
|
449
|
+
Los fakes interceptan servicios para asertar interacciones sin ejecutar efectos reales
|
|
450
|
+
(no se envían mails, no se encolan jobs, no se sube nada). Se activan al inicio del test.
|
|
451
|
+
|
|
452
|
+
### Mail
|
|
453
|
+
|
|
454
|
+
```php
|
|
455
|
+
use Illuminate\Support\Facades\Mail;
|
|
456
|
+
use App\Mail\OrderShipped;
|
|
457
|
+
|
|
458
|
+
it('envía el mail de despacho', function () {
|
|
459
|
+
Mail::fake();
|
|
460
|
+
|
|
461
|
+
$order = Order::factory()->create();
|
|
462
|
+
OrderService::ship($order);
|
|
463
|
+
|
|
464
|
+
Mail::assertSent(OrderShipped::class, function (OrderShipped $mail) use ($order) {
|
|
465
|
+
return $mail->order->is($order)
|
|
466
|
+
&& $mail->hasTo($order->user->email);
|
|
467
|
+
});
|
|
468
|
+
Mail::assertSentCount(1);
|
|
469
|
+
Mail::assertNotSent(\App\Mail\OrderCancelled::class);
|
|
470
|
+
});
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
### Queue
|
|
474
|
+
|
|
475
|
+
```php
|
|
476
|
+
use Illuminate\Support\Facades\Queue;
|
|
477
|
+
use App\Jobs\ProcessPodcast;
|
|
478
|
+
|
|
479
|
+
it('encola el procesamiento del podcast', function () {
|
|
480
|
+
Queue::fake();
|
|
481
|
+
|
|
482
|
+
Podcast::factory()->create()->process();
|
|
483
|
+
|
|
484
|
+
Queue::assertPushed(ProcessPodcast::class);
|
|
485
|
+
Queue::assertPushedOn('podcasts', ProcessPodcast::class); // en cola específica
|
|
486
|
+
Queue::assertPushed(ProcessPodcast::class, 1);
|
|
487
|
+
Queue::assertNothingPushed(); // si esperas 0
|
|
488
|
+
});
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
### Event
|
|
492
|
+
|
|
493
|
+
```php
|
|
494
|
+
use Illuminate\Support\Facades\Event;
|
|
495
|
+
use App\Events\OrderPaid;
|
|
496
|
+
|
|
497
|
+
it('dispara OrderPaid al pagar', function () {
|
|
498
|
+
Event::fake([OrderPaid::class]); // fakear solo este evento, dejar correr el resto
|
|
499
|
+
|
|
500
|
+
$order = Order::factory()->create();
|
|
501
|
+
$order->markAsPaid();
|
|
502
|
+
|
|
503
|
+
Event::assertDispatched(OrderPaid::class, fn (OrderPaid $e) => $e->order->is($order));
|
|
504
|
+
Event::assertDispatchedTimes(OrderPaid::class, 1);
|
|
505
|
+
});
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
### Http (cliente HTTP saliente)
|
|
509
|
+
|
|
510
|
+
```php
|
|
511
|
+
use Illuminate\Support\Facades\Http;
|
|
512
|
+
|
|
513
|
+
it('consulta la API de pagos y maneja la respuesta', function () {
|
|
514
|
+
Http::fake([
|
|
515
|
+
'api.pagos.test/charge' => Http::response(['id' => 'ch_123', 'paid' => true], 200),
|
|
516
|
+
'api.pagos.test/*' => Http::response([], 404),
|
|
517
|
+
]);
|
|
518
|
+
|
|
519
|
+
$result = PaymentGateway::charge(amount: 1000);
|
|
520
|
+
|
|
521
|
+
expect($result['id'])->toBe('ch_123');
|
|
522
|
+
|
|
523
|
+
Http::assertSent(fn ($request) =>
|
|
524
|
+
$request->url() === 'https://api.pagos.test/charge'
|
|
525
|
+
&& $request['amount'] === 1000
|
|
526
|
+
);
|
|
527
|
+
Http::assertSentCount(1);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// Prevenir cualquier llamada real no fakeada (recomendado en CI):
|
|
531
|
+
Http::preventStrayRequests();
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
### Storage
|
|
535
|
+
|
|
536
|
+
```php
|
|
537
|
+
use Illuminate\Support\Facades\Storage;
|
|
538
|
+
use Illuminate\Http\UploadedFile;
|
|
539
|
+
|
|
540
|
+
it('guarda el avatar subido', function () {
|
|
541
|
+
Storage::fake('public');
|
|
542
|
+
|
|
543
|
+
$this->actingAs(User::factory()->create())
|
|
544
|
+
->post('/profile/avatar', [
|
|
545
|
+
'avatar' => UploadedFile::fake()->image('me.jpg', 200, 200),
|
|
546
|
+
])
|
|
547
|
+
->assertRedirect();
|
|
548
|
+
|
|
549
|
+
Storage::disk('public')->assertExists('avatars/me.jpg');
|
|
550
|
+
Storage::disk('public')->assertMissing('avatars/viejo.jpg');
|
|
551
|
+
});
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
> Ordena los fakes según lo que pruebas: si testeas que algo se ENCOLA, usa `Queue::fake()`.
|
|
555
|
+
> Si testeas que el job HACE su trabajo, NO lo fakees: ejecútalo (`Bus::dispatchSync()` o
|
|
556
|
+
> llamando `handle()`) y asevera el efecto.
|
|
557
|
+
|
|
558
|
+
---
|
|
559
|
+
|
|
560
|
+
## Time travel
|
|
561
|
+
|
|
562
|
+
Para lógica que depende de fechas (vencimientos, trials, scheduling), congela o avanza el
|
|
563
|
+
reloj con los helpers de `TestCase`. Internamente usan Carbon test-now.
|
|
564
|
+
|
|
565
|
+
```php
|
|
566
|
+
it('marca el trial como vencido a los 14 días', function () {
|
|
567
|
+
$user = freezeTime(function () { // congela "ahora" durante el closure
|
|
568
|
+
return User::factory()->create(['trial_ends_at' => now()->addDays(14)]);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
$this->travel(13)->days();
|
|
572
|
+
expect($user->fresh()->onTrial())->toBeTrue();
|
|
573
|
+
|
|
574
|
+
$this->travel(2)->days(); // total: día 15
|
|
575
|
+
expect($user->fresh()->onTrial())->toBeFalse();
|
|
576
|
+
|
|
577
|
+
$this->travelBack(); // restaurar el reloj real
|
|
578
|
+
});
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
Variantes:
|
|
582
|
+
|
|
583
|
+
```php
|
|
584
|
+
$this->travelTo(now()->startOfYear()); // saltar a un instante exacto
|
|
585
|
+
$this->travel(5)->hours();
|
|
586
|
+
$this->travel(-1)->weeks();
|
|
587
|
+
|
|
588
|
+
// Ejecutar algo en un instante fijo y volver automáticamente:
|
|
589
|
+
$this->travelTo(Carbon\Carbon::parse('2026-12-31 23:59:00'), function () {
|
|
590
|
+
expect(now()->year)->toBe(2026);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
// Congelar sin avanzar (útil para evitar drift de microsegundos):
|
|
594
|
+
$this->freezeTime();
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
Equivalencia PHPUnit: idénticos métodos (`$this->travel`, `$this->travelTo`,
|
|
598
|
+
`$this->freezeTime`) porque vienen del trait `InteractsWithTime` del `TestCase` base.
|
|
599
|
+
|
|
600
|
+
---
|
|
601
|
+
|
|
602
|
+
## Lifecycle hooks (setup/teardown)
|
|
603
|
+
|
|
604
|
+
```php
|
|
605
|
+
beforeEach(function () {
|
|
606
|
+
$this->user = User::factory()->create(); // disponible en cada test del archivo
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
afterEach(function () {
|
|
610
|
+
// limpieza si hiciera falta (RefreshDatabase ya revierte la DB)
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
beforeAll(fn () => /* una vez antes de todos */);
|
|
614
|
+
afterAll(fn () => /* una vez al final */);
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
Equivalen a `setUp()` / `tearDown()` de PHPUnit (recuerda llamar `parent::setUp()` si
|
|
618
|
+
sobrescribes en una clase).
|
|
619
|
+
|
|
620
|
+
---
|
|
621
|
+
|
|
622
|
+
## Correr los tests
|
|
623
|
+
|
|
624
|
+
```bash
|
|
625
|
+
./vendor/bin/pest # toda la suite
|
|
626
|
+
./vendor/bin/pest tests/Feature/OrderTest.php # un archivo
|
|
627
|
+
./vendor/bin/pest --filter="encola" # por nombre/descripción
|
|
628
|
+
./vendor/bin/pest --group=slow # tests con ->group('slow')
|
|
629
|
+
./vendor/bin/pest --parallel # en paralelo (usa varios procesos)
|
|
630
|
+
./vendor/bin/pest --bail # detener en el primer fallo
|
|
631
|
+
./vendor/bin/pest --retry # reejecutar solo los que fallaron
|
|
632
|
+
|
|
633
|
+
php artisan test # wrapper de artisan (usa Pest si está)
|
|
634
|
+
php artisan test --filter=OrderTest
|
|
635
|
+
php artisan test --parallel
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
---
|
|
639
|
+
|
|
640
|
+
## Coverage con umbral mínimo
|
|
641
|
+
|
|
642
|
+
Requiere un driver de cobertura (`Xdebug` con `XDEBUG_MODE=coverage`, o `PCOV`).
|
|
643
|
+
|
|
644
|
+
```bash
|
|
645
|
+
# Reporte en consola:
|
|
646
|
+
./vendor/bin/pest --coverage
|
|
647
|
+
|
|
648
|
+
# Exigir un mínimo: falla (exit code != 0) si la cobertura baja del umbral.
|
|
649
|
+
./vendor/bin/pest --coverage --min=80
|
|
650
|
+
|
|
651
|
+
# Reporte HTML o Clover para CI:
|
|
652
|
+
./vendor/bin/pest --coverage --coverage-html=build/coverage
|
|
653
|
+
./vendor/bin/pest --coverage-clover=build/clover.xml
|
|
654
|
+
|
|
655
|
+
# Métrica de "type coverage" (porcentaje de tipos declarados, feature de Pest 3):
|
|
656
|
+
./vendor/bin/pest --type-coverage --min=100
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
En CI conviene fijar el driver y el umbral explícitamente:
|
|
660
|
+
|
|
661
|
+
```bash
|
|
662
|
+
XDEBUG_MODE=coverage ./vendor/bin/pest --coverage --min=80 --parallel
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
> Si `--coverage` reporta 0% o avisa que no hay driver, falta Xdebug/PCOV o
|
|
666
|
+
> `XDEBUG_MODE=coverage`. Sin driver, el flag `--coverage` no produce números reales.
|
|
667
|
+
|
|
668
|
+
---
|
|
669
|
+
|
|
670
|
+
## Workflow TDD recomendado
|
|
671
|
+
|
|
672
|
+
1. Escribir el test primero en `tests/Feature/` (rojo): describe el comportamiento esperado.
|
|
673
|
+
2. Preparar datos con factories y states; usar fakes para aislar efectos externos.
|
|
674
|
+
3. Implementar el código mínimo para pasar (verde).
|
|
675
|
+
4. Refactorizar con el test como red de seguridad.
|
|
676
|
+
5. Antes de cerrar: `./vendor/bin/pest --coverage --min=<umbral>` y `./vendor/bin/pint --test`.
|
|
677
|
+
|
|
678
|
+
## Qué NO hacer
|
|
679
|
+
|
|
680
|
+
- No fakees el servicio que estás probando: si testeas que un job hace su trabajo, ejecútalo.
|
|
681
|
+
- No dependas del orden entre tests: cada uno arranca con DB limpia (`RefreshDatabase`).
|
|
682
|
+
- No uses `now()` real en aserciones de tiempo: congela el reloj con `freezeTime()`/`travelTo()`.
|
|
683
|
+
- No referencies `app/Http/Kernel.php` — la estructura slim de Laravel ya no lo expone (el middleware va en `bootstrap/app.php`); verifica leyendo `bootstrap/app.php`.
|
|
684
|
+
- No corras `--coverage` sin driver: el número será falso (0%) y no protege nada.
|
|
685
|
+
- No metas llamadas HTTP reales en tests: `Http::fake()` + `Http::preventStrayRequests()`.
|
|
686
|
+
- No uses SQLite en memoria si el código bajo prueba depende de Postgres (pgvector, JSON ops).
|