Testing
Quittio ships a fake for fast feature tests, assertions for real e-invoices, a deterministic
invoice factory and named sample scenarios. The factory and the scenarios are part of
quittio/core and work without Laravel; the fake and the assertions need quittio/laravel.
Fake
use Quittio\Core\Data\InvoiceData;
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');
});
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 records every render call and returns results quickly. It takes the decisions of a
real run (prepare() of the core without validators) and 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 | None |
Not faked: check(), prepare(), read(), readFile(), validateFile(),
validatePath() and cancellation() run the real core. Responses, save(), mail
attachments and queue() work on the fake results.
Assertions and helpers of the fake (also callable through the facade):
| 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 |
$fake = Quittio::fake();
Quittio::document($quote)->htmlOnly()->render();
expect($fake->documents())->toHaveCount(1)
->and($fake->documents()[0]->hasPdf())->toBeFalse();
The fake stays active until the application is rebuilt for the next test.
Laravel fakes
Without Quittio::fake() the real core renders, so the usual Laravel fakes test the
integration end to end:
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Quittio\Laravel\Events\InvoiceRendered;
use Quittio\Laravel\Events\InvoiceValidationFailed;
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/');
});
Mail::fake() with Mail::assertSent(YourMail::class, fn ($mail) => $mail->hasAttachment($result))
checks attachments, because rendered results are Attachable. After changing
config('quittio.…') inside a test, call Quittio::flush() (see
Configuration).
Factory
InvoiceData::factory() (or Quittio\Core\Testing\InvoiceFactory::new()) builds
deterministic test invoices: 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();
| 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 with VATEX-EU-AE |
intraCommunity() |
Buyer Cliente S.r.l. (Rome, IT12345678901), lines in category K with VATEX-EU-IC |
export() |
Buyer Customer Inc. (New York, no VAT identifier), lines in category G 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(): InvoiceData |
Invoice data with totals and VAT breakdown (En16931Calculator) |
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 them as ZUGFeRD (with b2c() as plain PDF); withLines(3) gives
net 140.00, VAT 26.60 and an amount due of 166.60.
Scenarios
Quittio\Core\Testing\Scenarios holds the sample invoices of the preview, the visual tests
and the official validation runs. They are country neutral (VAT categories, no tax presets).
| 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 |
$invoice = Scenarios::make('sub-lines')->number('T-1')->build();
$result = Quittio::make($invoice)->profile(Scenarios::profile('sub-lines'))->format('zugferd')->render();
Assertions
The trait Quittio\Laravel\Testing\InteractsWithQuittio adds two assertions to a test
case:
// PHPUnit
use Quittio\Laravel\Testing\InteractsWithQuittio;
class InvoiceTest extends TestCase
{
use InteractsWithQuittio;
}
// Pest: in a test file, or for a directory in tests/Pest.php
uses(InteractsWithQuittio::class);
pest()->use(InteractsWithQuittio::class)->in('Feature');
| 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. |
$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');
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 (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.
Official validators in CI
By default rendering and assertValidEInvoice() use the PHP rules and the XSD schemas
(php, xsd). The official Schematron rules (KoSIT validator, level L5) and the PDF/A
check (veraPDF, level L6) need Java and veraPDF. Run them in a separate test group:
use Quittio\Core\Validation\Drivers\KositArtifacts;
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']);
Quittio::flush();
}
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');
The values for jar, scenarios, repository and configurations are the ones
php artisan quittio:schemas:download prints. In mode cli every validation starts a JVM;
for many documents run the validator as a daemon (mode daemon, QUITTIO_KOSIT_URL; see
Validation): the download command also prints the start
command, 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@v4
- uses: shivammathur/setup-php@v2
with:
php: '8.4'
extensions: dom, libxml, mbstring, xmlwriter, zlib, intl, gd, bcmath, zip
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- uses: actions/cache@v4
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
(see Artisan commands).
Managed layer factories
quittio/managed ships Eloquent factories for its models (namespace
Quittio\Managed\Database\Factories). Tests need the published Managed migrations
(quittio:install --managed, see Managed layer) and a refreshed
database.
Issuer::factory() (IssuerFactory) creates issuer profiles:
| 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 |
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();
Issuing takes the number from the issuer’s sequence and renders the documents in a queued
job after the transaction (use Queue::fake() or quittio-managed.queue.render_sync). See
Managed layer for the lifecycle and
Testing against MySQL and PostgreSQL.