目录

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.

dart
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.

dart
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

#
dart
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.