Testing

This page shows how to test code that creates invoices: fast feature tests with a fake, checks that your e-invoices are valid, test data and the official validators in CI. The examples use Pest; the same methods work in PHPUnit.

You want to test Use
That your code renders an invoice, without the cost of a real run Quittio::fake()
That the invoice data is a valid e-invoice, without rendering check()
Storage, events, the queue or mail with real rendering Laravel fakes
That a rendered PDF or XML is a valid e-invoice, or that the XML has not changed Assertions
An invoice to test with, without writing a builder chain Factory
Realistic sample invoices: many lines, reverse charge, foreign currency … Scenarios
Your files with the official validators (KoSIT, veraPDF) Official validators in CI
Models of the Managed layer Managed layer factories

The factory and the scenarios are part of quittio/core and work without Laravel. The fake and the assertions need quittio/laravel. Terms such as ZUGFeRD, profile or reverse charge are explained in E-invoicing basics.

Fake

The most common test checks that your code renders an invoice, e.g. when an order ships. Quittio::fake() replaces the rendering with a fast fake that records every render call. Afterwards you assert what was rendered:

use Quittio\Laravel\Facades\Quittio;
use Quittio\Laravel\Rendering\RenderedInvoice;

it('renders the invoice of an order', function () {
    $fake = Quittio::fake();

    $this->post('/orders/42/invoice')->assertOk();

    Quittio::assertRenderedCount(1);
    Quittio::assertRendered(fn (RenderedInvoice $r) => $r->format() === 'zugferd'
        && $r->data()->totals->amountDue->isEqualTo('1234.56'));
    expect($fake->rendered()[0]->data()->number)->toBe('RE-2026-0042');
});
  • assertRenderedCount(1) checks that exactly one invoice was rendered.
  • assertRendered() with a callback checks that at least one rendered invoice matches: here one in the format ZUGFeRD (zugferd, a PDF with embedded XML) with the expected amount due. The callback receives a RenderedInvoice; its data() holds the calculated invoice with totals.
  • Quittio::fake() returns the fake. $fake->rendered() lists the recorded results, in order.
  • The fake skips validation. To test that the invoice is a valid e-invoice, add a test with check().

A complete example with an order, a mail and both kinds of test is Testing on the Examples page.

How the fake works

Quittio::fake() replaces the manager with a Quittio\Laravel\Testing\QuittioFake and returns it. It is swapped into the facade and into the container. So code that injects QuittioManager, queued RenderInvoice jobs and the Managed layer use the fake as well.

The fake takes the decisions of a real run: it calls prepare() of the core without validators. Then it returns placeholder results instead of rendering. It skips validation, XML, template and PDF:

  Behaviour of the fake
Calculation Real: totals, VAT breakdown and the due date of defaults.due_days, so data() holds the amounts
Format As in a real run: format()/formats(), then defaults.format, then the automatic choice (country rules, format_resolver, Peppol lookup). Every format of the call is recorded under the main result, so formats() and as() work.
Profile As in a real run: profile() with a key, alias or Profile object, defaults.profile, the profile of the automatic choice or of the format, the EXTENDED upgrade; null for formats without XML
Errors An unknown format or profile, or a profile the format does not allow, throws a ConfigurationException like a real run
Validation None: validation() is an empty ValidationResult, no ValidationException
Outputs Placeholders according to the format: XML <fake number="…"/>, PDF %PDF-1.7 with a comment, HTML <p>{number}</p>, JSON of the data
File name Invoice_{number}.{ext}
Documents Calculated; fake HTML and, unless htmlOnly(), a fake PDF; file name {number}.{ext}
Events As in a real run: InvoiceRendering, InvoiceValidated (with the empty result, not in mode off) and InvoiceRendered, so Event::fake() with Event::assertDispatched(InvoiceRendered::class) works
Queue queue()->save() records the job (queued(), assertQueued()) and dispatches it like a real run: Queue::fake() sees it, a sync queue renders it with the fake

Not faked: check(), prepare(), read(), readFile(), validateFile(), validatePath() and cancellation() run the real core. Responses, save() and mail attachments work on the fake results.

The fake stays active until the application is rebuilt for the next test.

Assertions of the fake

These methods belong to the fake and are also callable through the facade (Quittio::assertRendered()):

Method Asserts / returns
assertRendered(?callable $callback = null) At least one invoice was rendered; with a callback, at least one RenderedInvoice for which it returns true
assertRenderedCount(int $count) Exactly $count invoices were rendered
assertNothingRendered() No invoice was rendered
assertDocumentRendered(?callable $callback = null) At least one document (quote, reminder, …) was rendered; with a callback, one RenderedDocument matches
rendered(): array The recorded RenderedInvoice results (the main format of each call), in order
documents(): array The recorded RenderedDocument results, in order
assertQueued(?callable $callback = null) At least one invoice was queued with queue()->save(); with a callback, one RenderInvoice job matches ($job->data() is the invoice, $job->disk, $job->path)
assertNothingQueued() No invoice and no document was queued
assertDocumentQueued(?callable $callback = null) At least one document was queued with Quittio::document(…)->queue()->save(); with a callback, one RenderDocument job matches ($job->data() is the document)
queued(): array The recorded RenderInvoice jobs, in order

Documents such as quotes and reminders (Quotes and other documents) are recorded separately from invoices:

$fake = Quittio::fake();

Quittio::document($quote)->htmlOnly()->render();

expect($fake->documents())->toHaveCount(1)
    ->and($fake->documents()[0]->hasPdf())->toBeFalse();

Validating the invoice data

A test with the fake does not show whether the invoice is a valid e-invoice. check() runs the real validation, including the XML, without rendering a PDF. It is never faked, so it also works while the fake is active:

use Quittio\Laravel\Facades\Quittio;

it('builds a valid e-invoice for an order', function () {
    $order = Order::factory()->create();

    $report = Quittio::make($order)->check();            // the real validation, without rendering

    expect($report->isValid())->toBeTrue()
        ->and($report->warnings())->toBe([]);
});
  • Order is a model of your application that implements ProvidesInvoiceData (Invoices for orders). Any invoice data works the same way: Quittio::make($invoice)->check().
  • check() returns a ValidationResult and never throws a ValidationException for invalid data. isValid() is true without errors; warnings() lists the warnings (The result).
  • It validates in the same formats and profiles as render() would. The PDF/A check (level L6) does not run, because it needs the rendered PDF (When validation runs).
  • To validate a rendered PDF or XML file, use assertValidEInvoice().

Laravel fakes

Without Quittio::fake() the real core renders. Then the usual Laravel fakes test the integration end to end: files on a disk, events and queued jobs. InvoiceData::factory() builds the test invoice (Factory):

use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Quittio\Core\Data\InvoiceData;
use Quittio\Laravel\Events\InvoiceRendered;
use Quittio\Laravel\Events\InvoiceValidationFailed;
use Quittio\Laravel\Facades\Quittio;
use Quittio\Laravel\Jobs\RenderInvoice;

it('stores the PDF', function () {
    Storage::fake('invoices');

    Quittio::make(InvoiceData::factory()->make())->format('zugferd')->render()->save('invoices', '2026/');

    Storage::disk('invoices')->assertExists('2026/Rechnung_RE-2026-0042.pdf');
});

it('dispatches the events', function () {
    Event::fake([InvoiceRendered::class, InvoiceValidationFailed::class]);

    Quittio::make(InvoiceData::factory()->make())->format('cii')->render();

    Event::assertDispatched(InvoiceRendered::class, fn ($e) => $e->result->format() === 'cii');
    Event::assertNotDispatched(InvoiceValidationFailed::class);
});

it('queues the rendering', function () {
    Queue::fake();

    Quittio::make(InvoiceData::factory()->make())->queue()->save('invoices', 'queued/');

    Queue::assertPushedOn('quittio', RenderInvoice::class, fn (RenderInvoice $job) => $job->path === 'queued/');
});
  • The factory’s invoice is German (locale de), so the file name starts with the German title “Rechnung” (invoice).
  • The events are described in Events, the queue in Queueing. quittio is the default queue name.
  • Rendered results are Attachable. So Mail::fake() with Mail::assertSent(YourMail::class, fn ($mail) => $mail->hasAttachment($result)) checks mail attachments.
  • After changing config('quittio.…') inside a test, the next call uses the new configuration (Configuration).

Assertions

The trait Quittio\Laravel\Testing\InteractsWithQuittio adds two assertions for rendered files to a test case. assertValidEInvoice() checks that a PDF or XML is a valid e-invoice. assertXmlMatchesSnapshot() checks that the XML has not changed since you last accepted it. Add the trait to your tests:

// PHPUnit
use Quittio\Laravel\Testing\InteractsWithQuittio;

class InvoiceTest extends TestCase
{
    use InteractsWithQuittio;
}

// Pest, in a single test file
uses(InteractsWithQuittio::class);

// Pest, for all tests of a directory (in tests/Pest.php)
pest()->use(InteractsWithQuittio::class)->in('Feature');

After each test the trait also resets the process-wide state of Quittio (Quittio\Core\Quittio::resetState()), e.g. base rates that a test registered.

Then use the assertions on the outputs of a rendered invoice:

$result = Quittio::make(InvoiceData::factory()->make())->formats(['zugferd', 'xrechnung-ubl'])->render();

$this->assertValidEInvoice($result->pdf(), 'en16931');
$this->assertValidEInvoice($result->as('xrechnung-ubl')->xml(), 'xrechnung');
$this->assertXmlMatchesSnapshot($result->xml(), 'zugferd-default');
Method Asserts
assertValidEInvoice(string $pdfOrXml, ?string $profile = null) Reads the PDF or XML content and validates it with all configured levels (quittio.validation.levels, see below). Fails with the code and message of every error; warnings do not fail. With $profile, the profile read from the file must be that profile (aliases are resolved, e.g. comfort).
assertXmlMatchesSnapshot(string $xml, ?string $name = null) Compares the canonicalised XML (whitespace between elements removed, re-indented) with a stored snapshot. The first run writes the snapshot and passes. Not well-formed XML fails.

The profile is the set of rules the file follows, e.g. en16931 or xrechnung (Profiles).

Snapshots

A snapshot is a stored copy of the XML. Later runs compare the XML with it, so that an unintended change of the output fails the test.

  • Snapshots are stored in __snapshots__/quittio/{name}.xml next to the test file.
  • The name is reduced to letters, digits, ., _ and -. Without a name it is {TestClass}__{test name}. Pest generates class and test names, so pass a name there.
  • To accept a changed XML, delete the snapshot file and run the test again.
  • Override the protected methods quittioSnapshotDirectory() and quittioSnapshotName() to change the location and the default name.

Outside test case classes

Outside test case classes, e.g. in Pest helper functions, Quittio\Laravel\Testing\EInvoiceAssertions provides the same assertions:

use Quittio\Laravel\Testing\EInvoiceAssertions;

(new EInvoiceAssertions)->assertValidEInvoice($pdf, 'xrechnung');
(new EInvoiceAssertions(base_path('tests/__snapshots__')))->assertXmlMatchesSnapshot($xml, 'order-42');

Its constructor takes the snapshot directory (default tests/__snapshots__/quittio below the working directory). Its default snapshot name is xml, so pass a name per snapshot.

Factory

Most tests need an invoice. InvoiceData::factory() (or Quittio\Core\Testing\InvoiceFactory::new()) builds one in a single line. The test invoices are deterministic: they contain no random values, so XML snapshots stay stable. Every method returns a new factory.

use Quittio\Core\Data\InvoiceData;

InvoiceData::factory()->make();                                   // one line, 19 %
InvoiceData::factory()->reverseCharge()->withLines(5)->make();
InvoiceData::factory()->intraCommunity()->calculated();
InvoiceData::factory()->b2c()->currency('USD')->number('X-1')->make();
InvoiceData::factory()->creditNote()->state(fn ($builder) => $builder->buyerReference('04011000-12345-03'))->make();

The base invoice is the one of the scenarios (Scenarios::base()):

  • number RE-2026-0042, issue date 2026-10-03, delivery date 2026-09-30, locale de;
  • seller Muster GmbH (DE123456789, Musterstadt), buyer Kunde AG (DE987654321), buyer reference PO-4711;
  • payment terms 14 days and a SEPA credit transfer.

With the German country pack the automatic choice renders these invoices as ZUGFeRD, and with b2c() as plain PDF. withLines(3) gives net 140.00, VAT 26.60 and an amount due of 166.60.

Method Effect
withLines(int $count) Number of lines (at least 1). Line i: “Item i”, quantity i pieces, net price 10 × i, VAT 19 % (or the category of the VAT mode)
reverseCharge() Buyer Client SARL (Paris, FR12345678901), lines in category AE (reverse charge) with VATEX-EU-AE
intraCommunity() Buyer Cliente S.r.l. (Rome, IT12345678901), lines in category K (intra-community supply) with VATEX-EU-IC
export() Buyer Customer Inc. (New York, no VAT identifier), lines in category G (export) with VATEX-EU-G
b2c() Consumer buyer Erika Mustermann (Berlin), no buyer reference
currency(string $currency) Invoice currency; other than EUR adds the VAT accounting currency EUR (rate 0.9 of 2026-10-02) and a credit transfer
number(string $number) Invoice number (default RE-2026-0042)
creditNote() Type code 381 with the preceding invoice RE-2026-0001 of 2026-09-01
state(callable $state) Calls $state(InvoiceBuilder $builder) for free changes; several states run in order
builder(): InvoiceBuilder The prepared builder
make(): InvoiceData Invoice data, not calculated (as passed to Quittio::make())
calculated(?TotalsCalculator $calculator = null): InvoiceData Invoice data with totals and VAT breakdown, calculated with $calculator (default En16931Calculator)

VATEX-EU-… are the codes of the VAT exemption reasons (Exemption reasons in the VAT breakdown).

Scenarios

Quittio\Core\Testing\Scenarios holds realistic sample invoices: the ones of the preview, the visual tests and the official validation runs. Use them when a test needs special content, e.g. 60 lines for page breaks or sub lines. They are country neutral: they use VAT categories directly, not the tax presets of a country pack.

use Quittio\Core\Testing\Scenarios;
use Quittio\Laravel\Facades\Quittio;

$invoice = Scenarios::make('sub-lines')->number('T-1')->build();
$result = Quittio::make($invoice)->profile(Scenarios::profile('sub-lines'))->format('zugferd')->render();

php artisan quittio:preview --scenario=simple renders a scenario with your template (quittio:preview).

Method Returns
Scenarios::names(): array All scenario names
Scenarios::make(string $name): InvoiceBuilder The builder of a scenario; an unknown name throws an InvalidArgumentException
Scenarios::profile(string $name): string Best profile: extended for sub-lines, peppol for peppol, otherwise en16931
Scenarios::base(): InvoiceBuilder Seller, buyer, dates and payment of all scenarios, without lines

Each scenario also has a static method (Scenarios::simple(), manyLines(), longTexts(), mixedVat(), reverseCharge(), intraEu(), export(), cancellation(), subLines(), foreignCurrency(), b2cGross(), allowances(), attachments(), peppol()).

Name Content
simple Two lines (24 h web development, monthly hosting with a period), 19 %, 2 % cash discount within 7 days
many-lines 60 lines with item numbers, 7 % and 19 % (page breaks)
long-texts Long note and description, item attributes
mixed-vat 7 %, 19 % and zero rated lines, 10 % document discount
reverse-charge French buyer, reverse charge (AE)
intra-eu Italian buyer, intra-community supply (K), delivery address, country of origin, HS code
export US buyer, export (G)
cancellation Credit note ST-2026-0007 cancelling RE-2026-0042
sub-lines Line with three sub lines, invoicee and delivery terms (profile EXTENDED)
foreign-currency USD invoice with the VAT accounting currency EUR
b2c-gross Consumer buyer, gross prices, 7 % and 19 %
allowances Line allowance and charge, document charge and allowance, price base quantity, list price with discount
attachments Embedded CSV time sheet and an external document reference
peppol Belgian buyer, Peppol endpoints (0208, 9930), reverse charge

Official validators in CI

By default, rendering and assertValidEInvoice() use Quittio’s PHP rules and the XSD schemas (levels php and xsd). Two official validators check more (Rules and validation):

  • the KoSIT validator (level L5) with the official Schematron rules of EN 16931, XRechnung and Peppol; KoSIT is the German agency that publishes XRechnung;
  • veraPDF (level L6), which checks that a PDF is a valid PDF/A file.

They need Java and veraPDF. Run them in a separate test group, so that your fast tests do not need them:

use Quittio\Core\Data\InvoiceData;
use Quittio\Core\Validation\Drivers\KositArtifacts;
use Quittio\Laravel\Facades\Quittio;

function useOfficialValidators(): void
{
    $root = storage_path('quittio/validators');            // php artisan quittio:schemas:download

    config()->set('quittio.validation.levels', ['php', 'xsd', 'kosit', 'verapdf']);
    config()->set('quittio.validation.drivers.kosit', [
        'mode' => 'cli',
        'jar' => $root.'/validator.jar',
        'scenarios' => $root.'/xrechnung/scenarios.xml',
        'repository' => $root.'/xrechnung',
        'configurations' => [['scenarios' => KositArtifacts::peppolScenarios(), 'repository' => $root]],
        'timeout' => 120,
    ]);
    config()->set('quittio.validation.drivers.verapdf', ['mode' => 'docker', 'image' => 'verapdf/cli']);
}

it('produces valid ZUGFeRD and XRechnung', function () {
    useOfficialValidators();
    $invoice = InvoiceData::factory()->state(fn ($b) => $b->buyerReference('04011000-12345-03'))->make();
    $result = Quittio::make($invoice)->formats(['zugferd', 'xrechnung-cii'])->render();

    $this->assertValidEInvoice($result->pdf(), 'en16931');
    $this->assertValidEInvoice($result->as('xrechnung-cii')->xml(), 'xrechnung');
})->group('official');
  • php artisan quittio:schemas:download downloads the validator and prints the values for jar, scenarios, repository and configurations.
  • In mode cli every validation starts a JVM (Java virtual machine). For many documents, run the validator as a daemon instead: mode daemon with QUITTIO_KOSIT_URL (KoSIT validator).
  • The download command also prints the start command of the daemon, with -P 8081 for the default URL http://127.0.0.1:8081. The validator’s own default port is 8080.
  • An unreachable validator fails the validation with KOSIT-UNAVAILABLE, unless fail_on_unavailable is false.

A GitHub Actions job for this group:

official-validation:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v7
    - uses: shivammathur/setup-php@v2
      with:
        php: '8.4'
        extensions: dom, libxml, mbstring, xmlwriter, zlib, intl, gd, bcmath, zip
    - uses: actions/setup-java@v6
      with:
        distribution: temurin
        java-version: '21'
    - uses: actions/cache@v6
      with:
        path: storage/quittio/validators
        key: quittio-validators-${{ hashFiles('composer.lock') }}
    - run: composer install --no-interaction --no-progress
    - run: php artisan quittio:schemas:download     # keeps existing files
    - run: docker pull verapdf/cli
    - run: vendor/bin/pest --group=official

Exclude the group from the fast test job with --exclude-group=official. To check a single file by hand: php artisan quittio:validate invoice.pdf --profile=en16931 --kosit --verapdf (Artisan commands).

Managed layer factories

The Managed layer is the optional package quittio/managed: it stores invoices in your database, numbers and archives them. It ships Eloquent factories for its models (namespace Quittio\Managed\Database\Factories). Tests need the published Managed migrations (quittio:install --managed, see Installation) and a refreshed database.

use Quittio\Core\Data\InvoiceData;
use Quittio\Managed\Models\Invoice;
use Quittio\Managed\Models\Issuer;

$issuer = Issuer::factory()->default()->create();
$invoice = Invoice::factory()->for($issuer)->create()->issue('2026-10-05');
$other = Issuer::factory()->company('Andere AG', 'DE111111111')->create(['key' => 'tenant-b']);
$draft = Invoice::factory()->for($issuer)->data(InvoiceData::factory()->reverseCharge()->make())->create();

Issuer::factory() (IssuerFactory) creates issuer profiles, the companies that issue invoices:

State Attributes
(definition) Key issuer-{10 random characters}; seller Muster GmbH with a random German VAT identifier (DE + 9 digits), Hauptstraße 1, 12345 Musterstadt, contact Max Muster, e-mail and electronic address rechnung@muster.de; country DE, time zone Europe/Berlin, bank account DE02120300000000202051 / BYLADEM1001, defaults locale de and due_days 14; not the default issuer. Every issuer is a company of its own and gets its own number space.
default() Key default, default issuer, seller VAT identifier DE123456789
company(string $name, string $vatId) Another company: own name and VAT identifier, Marktplatz 2, 54321 Anderstadt, electronic address rechnung@{slug of the name}.de

Invoice::factory() (InvoiceFactory) creates drafts:

State Attributes
(definition) Draft (status draft, kind invoice) with the data of InvoiceData::factory()->withLines(2)->make() (number empty, totals not yet calculated), for a new issuer (Issuer::factory())
data(InvoiceData $data) The columns of your invoice data instead

Issuing takes the number from the issuer’s sequence. The documents are rendered in a queued job after the transaction. In tests, use Queue::fake(), or set quittio-managed.queue.render_sync to render in the same request. See Managed layer for the lifecycle and Testing against MySQL and PostgreSQL.