How to mock JavaScript interop objects
In this tutorial, you'll learn how to mock JS objects so that you can test interop instance members without having to use a real implementation.
Background and motivation
#Mocking classes in Dart is usually done through overriding instance members. However, since extension types are used to declare interop types, all extension type members are dispatched statically and therefore overriding can't be used. This limitation is true for extension members as well, and therefore instance extension type or extension members can't be mocked.
While this applies to any non-external
extension type member, external
interop members are special as they invoke members on a JS value.
extension type Date(JSObject _) implements JSObject {
external int getDay();
}
As discussed in the Usage section, calling getDay()
will result in calling getDay()
on the JS object. Therefore, by using a different JSObject
, a different implementation of getDay
can be called.
In order to do this, there should be some mechanism of creating a JS object that has a property getDay
which when called, calls a Dart function. A simple way is to create a JS object and set the property getDay
to a converted callback e.g.
final date = Date(JSObject());
date['getDay'] = (() => 0).toJS;
While this works, this is prone to error and doesn't scale well when you are using many interop members. It also doesn't handle getters or setters properly. Instead, you should use a combination of createJSInteropWrapper
and @JSExport
to declare a type that provides an implementation for all the external
instance members.
Mocking example
#import 'dart:js_interop';
import 'package:expect/minitest.dart';
// The Dart class must have `@JSExport` on it or at least one of its instance
// members.
@JSExport()
class FakeCounter {
int value = 0;
@JSExport('increment')
void renamedIncrement() {
value++;
}
void decrement() {
value--;
}
}
extension type Counter(JSObject _) implements JSObject {
external int value;
external void increment();
void decrement() {
value -= 2;
}
}
void main() {
var fakeCounter = FakeCounter();
// Returns a JS object whose properties call the relevant instance members in
// `fakeCounter`.
var counter = createJSInteropWrapper<FakeCounter>(fakeCounter) as Counter;
// Calls `FakeCounter.value`.
expect(counter.value, 0);
// `FakeCounter.renamedIncrement` is renamed to `increment`, so it gets
// called.
counter.increment();
expect(counter.value, 1);
expect(fakeCounter.value, 1);
// Changes in the fake affect the wrapper and vice-versa.
fakeCounter.value = 0;
expect(counter.value, 0);
counter.decrement();
// Because `Counter.decrement` is non-`external`, we never called
// `FakeCounter.decrement`.
expect(counter.value, -2);
}
@JSExport
allows you to declare a class that can be used in createJSInteropWrapper
. createJSInteropWrapper
will create an object literal that maps each of the class' instance member names (or renames) to a JS callback, which is created using Function.toJS
. When called, the JS callback will in turn call the instance member. In the above example, getting and setting counter.value
gets and sets fakeCounter.value
.
You can specify only some members of a class to be exported by omitting the annotation from the class and instead only annotate the specific members. You can see more specifics on more specialized exporting (including inheritance) in the documentation of @JSExport
.
Note that this mechanism isn't specific to testing only. You can use this to provide a JS interface for an arbitrary Dart object, allowing you to essentially export Dart objects to JS with a predefined interface.
Unless stated otherwise, the documentation on this site reflects Dart 3.5.0. Page last updated on 2024-08-01. View source or report an issue.