JavaScript interop 객체를 모의(mock)하는 방법
JavaScript interop 객체를 모의(mock)하는 방법
테스트를 위해 Dart에서 JS interop 객체를 모의하는 방법을 배워요.
본문
이 튜토리얼에서는 실제 구현을 사용하지 않고도 interop 인스턴스 멤버를 테스트할 수 있도록 JS 객체를 모의하는 방법을 배워요.
배경과 동기
Dart에서 클래스를 모의하는 것은 보통 인스턴스 멤버를 오버라이딩해서 이루어져요. 그런데 interop 타입은 extension type으로 선언되기 때문에, 모든 extension type 멤버는 정적으로 dispatch되므로 오버라이딩을 사용할 수 없어요. 이 제한은 extension 멤버에도 동일하게 적용되며, 따라서 인스턴스 extension type 멤버나 extension 멤버는 모의할 수 없어요.
이는 external이 아닌 모든 extension type 멤버에 적용되지만, external interop 멤버는 특별해요. 이 멤버들은 JS 값에서 멤버를 호출하기 때문이에요.
extension type Date(JSObject _) implements JSObject {
external int getDay();
}
Usage 섹션에서 설명했듯이 getDay()를 호출하면 JS 객체에서 getDay()를 호출하게 돼요. 따라서 다른 JSObject를 사용하면 다른 구현의 getDay가 호출될 수 있어요.
이를 위해, 호출되면 Dart 함수를 호출하는 getDay 프로퍼티를 가진 JS 객체를 만드는 메커니즘이 있어야 해요. 간단한 방법은 JS 객체를 만들고 getDay 프로퍼티를 변환된 콜백으로 설정하는 거예요:
final date = Date(JSObject());
date['getDay'] = (() => 0).toJS;
이 방법은 동작하긴 하지만 오류가 나기 쉽고, interop 멤버를 많이 사용할 때는 잘 확장되지 않아요. 또한 getter나 setter를 제대로 처리하지 못해요. 대신 createJSInteropWrapper와 @JSExport를 조합해서 모든 external 인스턴스 멤버에 대한 구현을 제공하는 타입을 선언하는 게 좋아요.
모의 예제
import 'dart:js_interop';
import 'package:expect/minitest.dart';
// Dart 클래스는 `@JSExport`가 클래스 자체나 인스턴스 멤버 중
// 적어도 하나에 있어야 해요.
@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();
// `fakeCounter`의 관련 인스턴스 멤버를 호출하는 프로퍼티를 가진
// JS 객체를 반환해요.
var counter = createJSInteropWrapper(fakeCounter) as Counter;
// `FakeCounter.value`를 호출해요.
expect(counter.value, 0);
// `FakeCounter.renamedIncrement`는 `increment`로 이름이 바뀌므로
// 호출돼요.
counter.increment();
expect(counter.value, 1);
expect(fakeCounter.value, 1);
// fake의 변경은 wrapper에 반영되고 그 반대도 마찬가지예요.
fakeCounter.value = 0;
expect(counter.value, 0);
counter.decrement();
// `Counter.decrement`는 `external`이 아니므로, 우리는 결코
// `FakeCounter.decrement`를 호출하지 않았어요.
expect(counter.value, -2);
}
@JSExport와 createJSInteropWrapper
@JSExport를 사용하면 createJSInteropWrapper에서 사용할 수 있는 클래스를 선언할 수 있어요. createJSInteropWrapper는 각 인스턴스 멤버 이름(또는 rename)을 Function.toJS로 만들어지는 JS 콜백에 매핑하는 객체 리터럴을 만듭니다. JS 콜백이 호출되면 그 콜백이 차례로 인스턴스 멤버를 호출해요. 위 예제에서 counter.value를 가져오고 설정하는 것은 fakeCounter.value를 가져오고 설정하는 것과 같아요.
클래스에서 어노테이션을 생략하고 특정 멤버에만 어노테이션을 붙이면, 클래스의 일부 멤버만 export되도록 지정할 수 있어요. 상속을 포함한 더 세분화된 export에 대한 자세한 내용은 @JSExport의 문서에서 확인할 수 있어요.
참고로 이 메커니즘은 테스트에만 국한되지 않아요. 임의의 Dart 객체에 대한 JS 인터페이스를 제공하는 데 사용할 수 있으므로, 사전 정의된 인터페이스로 Dart 객체를 JS에 사실상 export할 수 있어요.