›
byrcsc/laravel-custom-fields · 1.x
Test code that reads and writes custom fields, including moving between tenants inside one test.
Nothing here needs a fake. The package is two database tables and a resolver, so a test that migrates and writes rows exercises the real thing. Everything below uses Pest.
Define the fields the test needs in the test, rather than relying on a seeder. It keeps each test independent and makes the field set the test is about visible in the test:
use App\Models\Customer;
use ByRcsc\LaravelCustomFields\Enums\FieldType;
use ByRcsc\LaravelCustomFields\Facades\CustomFields;
beforeEach(function (): void {
CustomFields::define(Customer::class, [
'key' => 'account_tier',
'type' => FieldType::Select,
'options' => ['standard', 'premium'],
'default' => 'standard',
]);
});With RefreshDatabase, both package tables are rolled back with everything
else, so definitions do not leak between tests.
it('stores an account tier against a customer', function (): void {
$customer = Customer::factory()->create();
$customer->setCustomField('account_tier', 'premium');
expect($customer->fresh()->getCustomField('account_tier'))->toBe('premium')
->and($customer->fresh()->customFields->all())->toBe(['account_tier' => 'premium']);
});fresh() matters here. setCustomField() unsets the loaded relation, so the
same instance reads correctly, but asserting on a fresh instance is what proves
the value reached the database.
Reach for the value model when the test is about storage rather than about the read:
use ByRcsc\LaravelCustomFields\Models\CustomFieldValue;
expect(CustomFieldValue::query()->count())->toBe(1)
->and(CustomFieldValue::query()->sole()->string_value)->toBe('premium')
->and(CustomFieldValue::query()->sole()->number_value)->toBeNull();That is also how you assert a field was cleared rather than set to null: the row is gone.
This is the one part with a sharp edge. The manager takes its resolver as a constructor dependency, so replacing the container binding is not enough on its own: whatever was already resolved is holding the old resolver, and the facade is holding that.
Drop both:
use ByRcsc\LaravelCustomFields\Contracts\TenantResolver;
use ByRcsc\LaravelCustomFields\CustomFields as CustomFieldsManager;
use Illuminate\Support\Facades\Facade;
function actingForTenant(?string $tenant): void
{
app()->instance(TenantResolver::class, new class($tenant) implements TenantResolver
{
public function __construct(private readonly ?string $tenant) {}
public function currentTenant(): ?string
{
return $this->tenant;
}
});
app()->forgetInstance(CustomFieldsManager::class);
Facade::clearResolvedInstance(CustomFieldsManager::class);
}Then a test can move between tenants in one process:
it('keeps each tenant value of a global field separate', function (): void {
CustomFields::define(Customer::class, ['key' => 'reference', 'type' => FieldType::Text]);
$customer = Customer::factory()->create();
actingForTenant('acme');
$customer->setCustomField('reference', 'ACME-40');
actingForTenant('globex');
$customer->setCustomField('reference', 'GBX-11');
expect($customer->fresh()->getCustomField('reference'))->toBe('GBX-11');
actingForTenant('acme');
expect($customer->fresh()->getCustomField('reference'))->toBe('ACME-40');
});Pass null to act for no tenant, which is what a global read does.
If your application uses a real tenancy package, make its tenant current the way it does instead. The rule is the same either way: after the current tenant changes, the manager has to be rebuilt.
The manager holds the definitions it has resolved for the life of the request, and a test is one request. Writes through the model clear it, so the common case needs nothing.
Call flush() after a write no model event can see, which in a test usually
means a mass update:
CustomFieldDefinition::query()->update(['sort_order' => 5]);
CustomFields::flush();A test about storage rather than about rules can turn validation off, so the rules do not reject a value before the type layer sees it:
config()->set('custom-fields.validate_on_write', false);Both gates are real and both are worth testing. This is how a test says which one it means.
use ByRcsc\LaravelCustomFields\Exceptions\UnknownCustomFieldException;
use Illuminate\Validation\ValidationException;
it('refuses an option the field does not offer', function (): void {
Customer::factory()->create()->setCustomField('account_tier', 'enterprise');
})->throws(ValidationException::class);
it('hides one tenant\'s fields from another', function (): void {
actingForTenant('globex');
Customer::factory()->create()->getCustomField('acme_only');
})->throws(UnknownCustomFieldException::class);UnknownCustomFieldException is the assertion that a field is genuinely not
visible, which is the one worth writing for anything tenant-scoped.
use ByRcsc\LaravelCustomFields\Models\CustomFieldValue;
it('reindexes when a custom field changes', function (): void {
$saved = 0;
CustomFieldValue::saved(function () use (&$saved): void {
$saved++;
});
Customer::factory()->create()->setCustomField('account_tier', 'premium');
expect($saved)->toBe(1);
});The package fires no events of its own, so Event::fake() has nothing
package-specific to assert on. Model event listeners registered inside a test
persist for the rest of the process, so register them in the test that needs
them rather than globally.
The listing guarantee is worth one test in an application that shows custom fields on an index page:
use Illuminate\Support\Facades\DB;
it('reads a page of customers without an n plus one', function (): void {
Customer::factory()->count(25)->create()
->each(fn (Customer $customer) => $customer->setCustomField('account_tier', 'premium'));
$counts = [];
foreach ([5, 25] as $size) {
DB::flushQueryLog();
DB::enableQueryLog();
Customer::query()->with('customFieldValues')->limit($size)->get()
->each(fn (Customer $customer) => $customer->customFields->all());
$counts[] = count(DB::getQueryLog());
DB::disableQueryLog();
}
expect($counts[0])->toBe($counts[1])
->and($counts[0])->toBeLessThanOrEqual(3);
});The claim being tested is that the count does not change with the size of the
page. Drop the with() and it does.
Assert a bound rather than an exact number for the count itself. Three is the ceiling, the records, their values, and the definitions, but the definitions query is skipped when the manager already resolved them earlier in the test, which defining and writing the fields usually does.