Testing¶
The native APIs only work inside Nativella. Elsewhere, in PHPUnit or on the command line, nativella_call() does not exist. Calling an API there throws a Nativella\Exception\RuntimeUnavailableException. Tests swap the client that makes the calls for Nativella\Client\FakeClient.
The fake client¶
Every API class takes a Nativella\Client in its constructor. FakeClient records the calls instead of making them, and answers what the native side would when nothing happens: no file picked, an empty clipboard, a dialog dismissed.
Take a service that exports notes where the user chooses:
use Nativella\Dialog;
use Nativella\Notification;
final readonly class Exporter
{
public function __construct(
private Dialog $dialog,
private Notification $notification,
) {
}
/**
* @param list<string> $notes
*/
public function export(array $notes): void
{
$path = $this->dialog->saveFile('Export notes', fileName: 'notes.md');
if (null === $path) {
return;
}
file_put_contents($path, implode("\n\n", $notes));
$this->notification->send('Exported', basename($path));
}
}
Its test drives the dialog with respond(), and checks the notification with calls():
use Nativella\Client\FakeClient;
use Nativella\Dialog;
use Nativella\Notification;
use PHPUnit\Framework\TestCase;
final class ExporterTest extends TestCase
{
public function testItExportsWhereTheUserChose(): void
{
$target = sys_get_temp_dir().'/exporter-test.md';
$client = new FakeClient();
$client->respond('dialog.saveFile', ['path' => $target]);
(new Exporter(new Dialog($client), new Notification($client)))->export(['Groceries', 'Ideas']);
$this->assertStringEqualsFile($target, "Groceries\n\nIdeas");
$this->assertSame('Exported', $client->calls('notification.send')[0]->payload['title']);
}
public function testNothingHappensWhenTheUserCancels(): void
{
// Unless told otherwise, dialog.saveFile answers ['path' => null].
$client = new FakeClient();
(new Exporter(new Dialog($client), new Notification($client)))->export(['Groceries']);
$this->assertSame([], $client->calls('notification.send'));
}
}
| Method | |
|---|---|
respond(string $method, array\|Throwable $result): void |
Answers the next call to $method with $result, or throws it. Call it several times to queue answers. |
calls(?string $method = null): list<Call> |
The calls made so far, all of them or those to $method. A Nativella\Client\Call has the method and the payload. |
reset(): void |
Forgets the calls and the queued answers. |
Methods are the native ones, <domain>.<method>: window.open, dialog.confirm, clipboard.readText… The payloads are the JSON objects the API classes send, with camelCase keys. The quickest way to learn them is to make the call and look at $client->calls().
To test how your code handles a failure, queue an exception:
use Nativella\Exception\NativeCallException;
$client->respond('globalShortcut.register', new NativeCallException('globalShortcut.register', new \RuntimeException('Already taken.')));
What it answers by default¶
Without a queued answer, FakeClient returns:
| Method | Answer |
|---|---|
app.info |
Nativella 0.1.0, dev.nativella.app |
app.isAutostartEnabled |
false |
app.path |
/home/nativella/<directory> |
app.thirdPartyNotices |
null, as a development build of Nativella |
barcodeScanner.scan |
null: cancelled |
biometrics.authenticate, biometrics.isAvailable |
false |
childProcess.all |
none |
childProcess.start |
the process id 1 |
clipboard.readImage, clipboard.readText |
null: empty |
dialog.ask, dialog.confirm |
false |
dialog.openDirectory, dialog.openFile, dialog.saveFile |
null: cancelled |
dialog.openFiles |
no files |
geolocation.currentPosition |
null: not allowed |
globalShortcut.isRegistered |
false |
nfc.isAvailable |
false |
nfc.read |
throws a NativeCallException, as the runtime does when NFC is not available |
power.status |
on mains, without a battery |
screen.all, screen.primary |
none |
screen.cursorPosition |
(0, 0) |
secrets.get |
null |
settings.all, settings.has |
no settings, false |
settings.get |
no value: Settings::get() returns its default |
system.info, system.theme |
Linux, x86_64, en-US; light |
updater.check, updater.install |
null: this version is the latest |
window.all |
none |
window.get, window.open |
the window asked for, main by default, focused, 1024 by 768 |
The other methods return nothing (void): they answer an empty result.
With a framework¶
The integrations replace the client in the container, so that the application under test gets a FakeClient wherever it injects an API:
Running outside Nativella¶
An application is a web application first, so it also runs with symfony serve, php artisan serve or php -S, in a browser, which is handy for working on templates. The native APIs throw there, so guard the calls that may run outside Nativella:
use Nativella\Nativella;
if ($nativella->isNative()) {
$window->setTitle($note->title);
}
isNative() of Nativella\Nativella (a service with Symfony, the Nativella facade with Laravel) asks the client. It is true in Nativella and under the fake client, so that the tests go through the native code as the application does. nativella_is_native() in Twig and @native in Blade answer the same. Runtime::isNative() only tells whether nativella_call() exists, which is false in tests, fake client or not, so keep it for where there is no client.