Версія v1.6.5 Попередні версії: документація на GitHub
Тестування
На цій сторінці
- Налаштування
- Доступ до панелі
- Entity: створення, зміна, видалення
- Policies і скоупінг
- Дії
- Через HTTP
- handle() напряму
- Фонові дії
- Фільтри
- Метрики й дашборди
- Журнал аудиту й сповіщення
- Журнал аудиту
- Сповіщення
- Розширення
- InteractsWithLarsadmin
- Права запису: $abilities
- Поле розширення в дії
- vitest: mountField у дії
- CI
Як тестувати панель у власному застосунку: доступ, Entity, policies, дії (зокрема фонові), фільтри, метрики,
журнал аудиту й сповіщення. Остання частина для авторів розширень: трейт InteractsWithLarsadmin і помічники
kit/testing.js.
Панель є звичайним набором маршрутів Laravel, тож тести пишуться стандартними засобами: HTTP-запити від імені
користувача, перевірки бази, Bus::fake(), Notification::fake(). Приклади нижче на Pest;
з PHPUnit усе так само.
Приклади спираються на Entity App\Larsadmin\Post (модель App\Models\Post, поля title і status), policy
App\Policies\PostPolicy, дію MarkLive, фільтр StatusFilter і метрику NewPosts з попередніх розділів.
Налаштування
tests/Pest.php застосунку Laravel уже підходить; потрібна лише база, що очищується між тестами:
pest()->extend(Tests\TestCase::class)
->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->in('Feature');
- Міграції.
RefreshDatabaseвиконує міграції застосунку, зокрема опублікованіlarsadmin:install(larsadmin-migrations: журнал аудиту й запуски фонових дій). Для сповіщень і фонових дій потрібна ще таблицяnotifications(php artisan make:notifications-table). - Gate панелі. У тестах
APP_ENV=testing, тожallow_localне діє і панель перевіряє gateviewLarsadminз вашогоLarsadminServiceProvider. Створюйте користувача, якого пускає справжній gate, або перевизначте gate у тесті (на наступний тест це не впливає: Laravel створює застосунок заново, і провайдер визначає gate знову). - Асети й Node не потрібні. Сторінки панелі рендеряться й без опублікованих
public/vendor/larsadmin. - Статичний стан. Реєстрації панелі (
Larsadmin::entities(),Larsadmin::auth(), розширення, tools) статичні. Провайдер реєструє свої знову під час кожного boot застосунку, але те, що тест додає сам (ще одну Entity, розширення, tool), лишається на наступні тести. Скидайте цеLarsadmin::flushState()вafterEach(): наступний тест отримає реєстрації провайдера заново.
Спільна заготовка для прикладів:
use App\Models\User;
use Illuminate\Support\Facades\Gate;
beforeEach(function () {
Gate::define('viewLarsadmin', fn (User $user) => $user->is_admin);
$this->admin = User::factory()->create(['is_admin' => true]);
});
Доступ до панелі
it('sends guests to the login page', function () {
$this->get('/admin')->assertRedirect(route('larsadmin.login'));
});
it('lets admins in and keeps everyone else out', function () {
$this->actingAs($this->admin)->get('/admin')->assertOk();
$this->actingAs(User::factory()->create(['is_admin' => false]))->get('/admin')->assertForbidden();
});
Шлях панелі береться з larsadmin.path (за замовчуванням admin). Імена маршрутів мають префікс
larsadmin., тож у тестах зручно писати route('larsadmin.entities.index', 'posts').
Entity: створення, зміна, видалення
Маршрути Entity (усі під {path}, імена з префіксом larsadmin.):
| Метод | URI | Ім'я | Результат |
|---|---|---|---|
| GET | entities/{entity} |
entities.index |
список; 403 без viewAny |
| GET | entities/{entity}/create |
entities.create |
форма створення; 403 без create |
| POST | entities/{entity} |
entities.store |
редирект на detail нового запису; помилки валідації в сесії |
| GET | entities/{entity}/{id} |
entities.show |
detail; 404 поза detailQuery |
| GET | entities/{entity}/{id}/edit |
entities.edit |
форма редагування |
| PUT | entities/{entity}/{id} |
entities.update |
редирект на detail |
| DELETE | entities/{entity}/{id} |
entities.destroy |
редирект на список |
| POST | entities/{entity}/actions/{action} |
entities.actions.run |
дія |
{entity} є uriKey() Entity (за замовчуванням ім'я класу в kebab-case у множині: Post дає posts). Тіло
запиту містить значення полів за їхніми атрибутами, як їх надсилає форма.
use App\Models\Post;
it('creates a post', function () {
$this->actingAs($this->admin)
->post('/admin/entities/posts', ['title' => 'Hello', 'status' => 'draft'])
->assertRedirect('/admin/entities/posts/'.Post::sole()->id);
expect(Post::sole()->title)->toBe('Hello');
});
it('validates the form', function () {
$this->actingAs($this->admin)
->post('/admin/entities/posts', ['title' => ''])
->assertSessionHasErrors('title');
expect(Post::count())->toBe(0);
});
it('updates and deletes a post', function () {
$post = Post::factory()->create();
$this->actingAs($this->admin)
->put("/admin/entities/posts/{$post->id}", ['title' => 'Changed', 'status' => 'live'])
->assertRedirect("/admin/entities/posts/{$post->id}");
expect($post->fresh()->title)->toBe('Changed');
$this->delete("/admin/entities/posts/{$post->id}")->assertRedirect('/admin/entities/posts');
expect(Post::count())->toBe(0);
});
Тести через HTTP перевіряють усе разом: правила полів, readonly, canSee, policy, скоупінг, хуки моделі. Самі
props сторінок Inertia не є стабільним API панелі; перевіряйте статуси, редиректи, помилки валідації й стан
бази.
Якщо перевіряєте компонент сторінки через assertInertia(), передайте false другим аргументом
component(). За замовчуванням inertia.testing.ensure_pages_exist увімкнено, і Inertia шукає файл сторінки
серед Vue-сторінок вашого застосунку. Сторінки панелі лежать у пакеті, тож без false перевірка падає з
Inertia page component file [Entities/Index] does not exist.
use Inertia\Testing\AssertableInertia as Assert;
it('opens the posts list', function () {
$this->actingAs($this->admin)
->get('/admin/entities/posts')
->assertOk()
->assertInertia(fn (Assert $page) => $page->component('Entities/Index', false));
});
Сторінки панелі: Entities/Index, Entities/Create, Entities/Detail, Entities/Edit, Dashboard.
Policies і скоупінг
Policy є звичайною policy Laravel: її методи можна тестувати напряму, а через HTTP перевіряти, що панель їх застосовує.
use App\Models\Post;
it('forbids deleting a published post', function () {
$post = Post::factory()->create(['status' => 'live']);
expect($this->admin->can('delete', $post))->toBeFalse();
$this->actingAs($this->admin)->delete("/admin/entities/posts/{$post->id}")->assertForbidden();
expect($post->fresh())->not->toBeNull();
});
// indexQuery() Entity лишає тільки записи поточного користувача (Авторизація: скоупінг рядків)
it('hides other authors\' posts', function () {
$foreign = Post::factory()->create(['user_id' => User::factory()]);
$this->actingAs($this->admin)->get("/admin/entities/posts/{$foreign->id}")->assertNotFound();
});
| Перевірка | Відповідь панелі |
|---|---|
немає доступу до панелі (viewLarsadmin) |
403 (гість: редирект на вхід) |
немає viewAny |
403 на список, дії й метрики Entity |
немає view, create, update, delete |
403 |
запис поза detailQuery (indexQuery) |
404 |
Модель без policy регулює larsadmin.allow_without_policy (за замовчуванням true: дозволено все). Якщо
покладаєтеся на fail closed, перевірте в тестах, що policy зареєстрована: expect(Gate::getPolicyFor(Post::class))->not->toBeNull()
(Авторизація).
Дії
Через HTTP
Дія запускається POST {path}/entities/{entity}/actions/{action} з полем resources (список ID або all) і
значеннями полів дії (Як виконується дія). {action} є uriKey() дії
(MarkLive дає mark-live).
use App\Models\Post;
it('publishes the selected posts', function () {
[$a, $b, $c] = Post::factory()->count(3)->create(['status' => 'draft']);
$this->actingAs($this->admin)
->from('/admin/entities/posts')
->post('/admin/entities/posts/actions/mark-live', ['resources' => [$a->id, $b->id], 'note' => 'ok'])
->assertRedirect('/admin/entities/posts')
->assertSessionHasNoErrors();
expect($a->fresh()->status)->toBe('live')
->and($c->fresh()->status)->toBe('draft');
});
it('validates the action fields', function () {
$post = Post::factory()->create();
$this->actingAs($this->admin)
->post('/admin/entities/posts/actions/mark-live', ['resources' => [$post->id], 'note' => str_repeat('x', 300)])
->assertSessionHasErrors('note');
});
it('runs on all matching records with the filters', function () {
Post::factory()->count(2)->create(['status' => 'draft']);
Post::factory()->create(['status' => 'archived']);
$this->actingAs($this->admin)->post('/admin/entities/posts/actions/mark-live', [
'resources' => 'all',
'filters' => ['status-filter' => 'draft'],
]);
expect(Post::where('status', 'live')->count())->toBe(2);
});
- Записи, які не проходять
authorizedToRun()(за замовчуваннямupdateз policy), пропускаються; якщо не пройшов жоден, відповідь 403. ПрихованаcanSee()дія дає 404. resources: 'all'застосовуєindexQuery, фільтри й пошук і має лімітactions.max_all; більше записів дає помилку валідаціїresources.
handle() напряму
Логіку дії можна перевірити без HTTP: ActionFields є Illuminate\Support\Fluent.
use App\Larsadmin\Actions\MarkLive;
use App\Models\Post;
use Maxcxam\Larsadmin\Actions\ActionFields;
it('marks posts live', function () {
$post = Post::factory()->create(['status' => 'draft']);
(new MarkLive)->handle(new ActionFields(['note' => 'ok']), collect([$post]));
expect($post->fresh()->status)->toBe('live');
});
Такий тест не перевіряє авторизацію, валідацію полів, чанки й транзакції; для них потрібен HTTP-тест.
Фонові дії
Дія з $queued іде в чергу лише з увімкненими сповіщеннями (larsadmin.notifications.enabled), інакше вона
виконується синхронно. Потрібні таблиці larsadmin_action_runs і notifications
(Сповіщення та фонові дії).
Що дія потрапила в чергу:
use App\Models\Post;
use Illuminate\Support\Facades\Bus;
use Maxcxam\Larsadmin\Actions\Queued\ActionRun;
it('queues the export', function () {
config(['larsadmin.notifications.enabled' => true]);
Bus::fake();
$post = Post::factory()->create();
$this->actingAs($this->admin)
->post('/admin/entities/posts/actions/recalculate-views', ['resources' => [$post->id]])
->assertRedirect();
$run = ActionRun::sole();
expect($run->status)->toBe(ActionRun::QUEUED)
->and($run->action)->toBe('recalculate-views')
->and($run->total)->toBe(1);
});
Що дія відпрацювала до кінця: з драйвером sync запуск виконується в тому самому запиті, від імені того самого
користувача, з усіма перевірками воркера (Драйвер sync).
use Illuminate\Support\Facades\Event;
use Maxcxam\Larsadmin\Events\ActionRunFinished;
it('finishes the run and notifies the user', function () {
config(['larsadmin.notifications.enabled' => true, 'queue.default' => 'sync']);
Event::fake([ActionRunFinished::class]);
$post = Post::factory()->create();
$this->actingAs($this->admin)
->post('/admin/entities/posts/actions/recalculate-views', ['resources' => [$post->id]]);
expect(ActionRun::sole()->status)->toBe(ActionRun::COMPLETED)
->and($this->admin->notifications()->count())->toBe(1);
Event::assertDispatched(ActionRunFinished::class, fn (ActionRunFinished $event) => $event->run->status === ActionRun::COMPLETED);
});
Статуси запуску: ActionRun::QUEUED, RUNNING, COMPLETED, FAILED, CANCELLED. Поле payload (зашифровані
вибір і значення полів) стирається, коли запуск завершено. Значення полів фонової дії мають бути скалярами,
масивами, датами чи enum: інше дає LogicException ще до постановки в чергу.
Фільтри
Фільтр можна перевірити напряму: normalize() перетворює сире значення з запиту, apply() звужує запит.
use App\Larsadmin\Filters\StatusFilter;
use App\Models\Post;
use Illuminate\Http\Request;
it('filters by status', function () {
Post::factory()->create(['status' => 'draft']);
Post::factory()->create(['status' => 'live']);
$filter = StatusFilter::make();
$request = Request::create('/');
expect($filter->normalize($request, 'live'))->toBe('live')
->and($filter->normalize($request, 'bogus'))->toBeNull()
->and($filter->apply($request, Post::query(), 'live')->pluck('status')->all())->toBe(['live']);
});
normalize() за замовчуванням приймає лише ключі з options(); null означає «фільтр не активний». Разом зі
скоупінгом фільтри перевіряються через дію з resources: 'all' (приклад вище). Фільтр не є межею
безпеки: обмеження доступу тестуйте на indexQuery, а не на фільтрах.
Метрики й дашборди
Картки вантажать дані з ендпоінтів GET {path}/api/dashboards/{dashboard}/metrics/{metric}?range= і
GET {path}/api/entities/{entity}/metrics/{metric}?range=. Формат відповіді стабільний
(Метрики).
use App\Models\Post;
it('counts new posts for the range', function () {
$this->travelTo(now()->startOfDay()->addHours(12));
Post::factory()->count(2)->create();
Post::factory()->create(['created_at' => now()->subDays(40)]);
$this->actingAs($this->admin)
->getJson('/admin/api/dashboards/content/metrics/new-posts?range=30')
->assertOk()
->assertJsonPath('result.value', 2);
});
it('hides the entity metric without viewAny', function () {
Gate::before(fn ($user, string $ability) => $ability === 'viewAny' ? false : null);
$this->actingAs($this->admin)
->getJson('/admin/api/entities/posts/metrics/new-posts')
->assertForbidden();
});
- Дашборд має бути зареєстрований у
dashboards()провайдера, а картка повернута з йогоcards(); інакше 404. Невидимі (canSee) дашборд чи картка теж дають 404. - Періоди рахуються від поточного моменту в
larsadmin.timezone: фіксуйте часtravelTo(). - Метрики з
cacheFor()кешуються; між тестами кеш очищується, якщо вphpunit.xmlCACHE_STORE=array(стандарт Laravel).
Журнал аудиту й сповіщення
Журнал аудиту
Увімкніть журнал у тесті й перевірте записи моделі Maxcxam\Larsadmin\Audit\AuditEntry
(Журнал аудиту):
use App\Models\Post;
use Maxcxam\Larsadmin\Audit\AuditEntry;
it('records the change', function () {
config(['larsadmin.audit.enabled' => true]);
$post = Post::factory()->create(['title' => 'Old', 'status' => 'draft']);
$this->actingAs($this->admin)
->put("/admin/entities/posts/{$post->id}", ['title' => 'New', 'status' => 'draft']);
$entry = AuditEntry::query()->sole();
expect($entry->event)->toBe('updated')
->and($entry->entity)->toBe('posts')
->and($entry->actor_id)->toBe((string) $this->admin->id)
->and($entry->changes)->toBe(['title' => ['old' => 'Old', 'new' => 'New']]);
});
Поля з ->sensitive() зберігаються як ['masked' => true]. Запис журналу не можна змінити чи видалити через
Eloquent (LogicException). Подію Maxcxam\Larsadmin\Events\AuditRecorded можна перехопити Event::fake().
Сповіщення
LarsadminNotification є звичайним сповіщенням Laravel, тож працює Notification::fake():
use Illuminate\Support\Facades\Notification;
use Maxcxam\Larsadmin\Notifications\LarsadminNotification;
it('tells the admin the import finished', function () {
Notification::fake();
app(\App\Imports\ProductImport::class)->run($this->admin);
Notification::assertSentTo($this->admin, LarsadminNotification::class,
fn (LarsadminNotification $notification) => $notification->toArray($this->admin)['level'] === 'success');
});
toArray() повертає title, body, level, icon, url, urlLabel, run.
Розширення
Скелет пакета розширення (larsadmin:extension) уже має tests/TestCase.php на Orchestra Testbench,
tests/Pest.php і приклади тестів поля, картки й HTTP (Розширення: тестування).
InteractsWithLarsadmin
Трейт Maxcxam\Larsadmin\Testing\InteractsWithLarsadmin працює і в застосунку, і в пакеті розширення (Pest і
PHPUnit) та використовує лише публічний API:
pest()->extend(Tests\TestCase::class)
->use(Maxcxam\Larsadmin\Testing\InteractsWithLarsadmin::class)
->in('Feature');
| Метод | Що робить |
|---|---|
serializeField(Field $field, ?Model $record = null, string $context = Field::DETAIL, ?string $entity = null, array $abilities = []): array |
серіалізує поле, як панель для сторінки; з $entity (клас чи uriKey) спершу викликає bindTo(); $abilities (з 1.6) задає права на запис |
fillField(Field $field, Model $record, array $input, array $files = [], string $context = Field::UPDATE): Model |
валідує ввід правилами поля, викликає fillInto() з журналом файлів, зберігає й виконує відкладене замикання в транзакції |
cardData(DataCard $card, array $query = [], ?Authenticatable $user = null, string $owner = 'dashboard:test'): mixed |
canSee() (AuthorizationException), rules() (ValidationException), data() |
assertExtensionRegistered(string $name) |
розширення зареєстроване |
extensionAssetResponse(string $name, string $file): TestResponse |
GET версійованого URL файлу від імені поточного користувача |
Права запису: $abilities
Field::authorizedTo('view' | 'update' | 'delete') (з 1.6) відповідає правами, які панель уже обчислила для
рядка. У тесті їх задає $abilities: відсутні права дорівнюють false, інші ключі дають помилку. Без запису й
у контексті create права ігноруються, і authorizedTo() завжди false.
use Maxcxam\Larsadmin\Fields\Field;
// Поле додає 'canEdit' => $this->authorizedTo('update') у jsonSerialize()
it('offers inline editing only with the update right', function () {
$post = new Post(['rating' => 4]);
$field = Rating::make('Рейтинг', 'rating');
expect($this->serializeField($field, $post, Field::INDEX, abilities: ['update' => true])['canEdit'])->toBeTrue()
->and($this->serializeField($field, $post, Field::INDEX)['canEdit'])->toBeFalse()
->and($this->serializeField($field, null, Field::CREATE, abilities: ['update' => true])['canEdit'])->toBeFalse();
});
Поле розширення в дії
З 1.6 поля розширень працюють у модальних вікнах дій. Дія отримує значення Field::actionValue(Request):
за замовчуванням це значення, яке fill() пише в атрибут. Його можна перевірити напряму або через HTTP-запуск
дії з цим полем:
use Illuminate\Http\Request;
it('hands the action the chosen rating', function () {
$request = Request::create('/', 'POST', ['rating' => '4']);
expect(Rating::make('Рейтинг', 'rating')->actionValue($request))->toBe(4);
});
Поля з файлами (File, Image, $multipart) у діях не підтримуються: дія з таким полем у fields() кидає
LogicException. У фоновій дії значення має бути скаляром, масивом, датою чи enum.
vitest: mountField у дії
mountField() з kit/testing.js монтує компонент форми з контрактом панелі
(vitest). З опцією action (з 1.6) поле монтується як у модальному вікні дії:
field.inAction дорівнює true, context за замовчуванням 'create', проп action дорівнює переданому
{ uriKey, entity }, resourceId дорівнює null, якщо не задано інше.
import { expect, it } from 'vitest'
import { mountField } from 'larsadmin-kit/testing.js'
import RatingInput from './RatingInput.vue'
it('works in an action modal', async () => {
const wrapper = mountField(RatingInput, {
field: { attribute: 'rating', value: null, max: 5 },
action: { uriKey: 'rate-posts', entity: 'posts' },
})
await wrapper.findAll('input[type="radio"]')[2].setValue(true)
expect(wrapper.emitted('update:modelValue').at(-1)).toEqual([3])
})
CI
- Тести застосунку з панеллю не потребують Node, npm чи опублікованих асетів: достатньо
composer installіphp artisan test(абоvendor/bin/pest). - SQLite у пам'яті (
DB_CONNECTION=sqlite,DB_DATABASE=:memory:уphpunit.xml) підходить для більшості тестів. Метрики з трендами по колонкахDATEна SQLite зсуваються на день: використовуйтеDATETIME(Метрики). QUEUE_CONNECTION=sync(стандартphpunit.xml) виконує фонові дії в тому самому запиті; для перевірки постановки в чергу використовуйтеBus::fake().- Журнал аудиту й сповіщення вимкнено за замовчуванням: вмикайте їх у конкретних тестах через
config(), а не вphpunit.xml, щоб інші тести не залежали від їхніх таблиць. - MCP-сервер у тестах не реєструється (
APP_ENV=testingбезLARSADMIN_MCP=true), тожlaravel/mcpу CI не заважає. - Пакет розширення:
composer test(Pest + Testbench),npm test(vitest),npm run buildі перевірка публічного APInode vendor/maxcxam/larsadmin/kit/check-public-api.mjs .(Перевірка публічного API).
Далі: Встановлення.