›
›
›
  1. docs
  2. ›
  3. byrcsc/laravel-custom-fields
1.x
Browse documentationOpenClose

Getting started

  • Introduction
  • Installation and setup
  • Quick start

Core concepts

  • Defining fields
  • Field types and storage
  • Tenant scoping
  • Reading and writing values
  • Validation
  • Filtering records

Operations

  • Changing and deleting fields
  • Queries and eager loading
  • Events and listeners

Reference

  • Configuration
  • Console commands
  • Exceptions
  • Testing
  • Troubleshooting

Getting started

  • Introduction
  • Installation and setup
  • Quick start

Core concepts

  • Defining fields
  • Field types and storage
  • Tenant scoping
  • Reading and writing values
  • Validation
  • Filtering records

Operations

  • Changing and deleting fields
  • Queries and eager loading
  • Events and listeners

Reference

  • Configuration
  • Console commands
  • Exceptions
  • Testing
  • Troubleshooting

byrcsc/laravel-custom-fields · 1.x

Testing.

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.

Defining fields in a test

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.

The happy path

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.

Asserting on the stored row

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.

Switching tenants

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.

Flushing resolved definitions

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();

Testing without write validation

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.

Asserting on failures

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.

Events

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.

Counting queries

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.

What to read next

  • Tenant scoping for what the resolver's answer changes.
  • Queries and eager loading for the guarantee the last test covers.
  • Exceptions for what each failure throws.
PreviousExceptionsNextTroubleshooting
View source

On this page

  1. Defining fields in a test
  2. The happy path
  3. Asserting on the stored row
  4. Switching tenants
  5. Flushing resolved definitions
  6. Testing without write validation
  7. Asserting on failures
  8. Events
  9. Counting queries
  10. What to read next