Тестирование реактивности Svelte
В open-source проекте, который я разрабатываю совместно с Clare Macrae, нам нужно было разработать функционал Svelte-компонента — но сначала требовалось покрыть поведение существующего компонента тестами вида «пользователь сделал это, проверить что компонент повел себя вот так».
Проблема в том компоненте — реактивность. Это редактор дат, который должен «возвращать» значение, обработанное внутри. Например, когда пользователь набирает в поле ввода tm и пробел, компонент должен автодополнить это до tomorrow. Можно было покрыть тестами только реактивную часть ($: {...}), но это лишь часть поведения компонента. Зато когда всё поведение покрыто тестами, можно начать разрабатывать новое поведение с новыми тестами.
Рассмотрим Svelte-компонент ниже — назовем его DateEditor. Это упрощенная версия нашего реального DateEditor.
<script lang="ts">
import {
doAutocomplete,
parseTypedDateForDisplayUsingFutureDate,
} from "../somewhere";
// Тип даты
export let id:
| "start"
| "scheduled"
| "due"
| "done"
| "created"
| "cancelled";
// Значение, которое обновляет реактивная часть и которое будет прочитано снаружи
export let date: string;
export let forwardOnly: boolean;
let parsedDate: string;
$: {
date = doAutocomplete(date);
parsedDate = parseTypedDateForDisplayUsingFutureDate(id, date, forwardOnly);
}
</script>
<input bind:value="{date}" {id} type="text" />
<!-- Поведение этой части нетривиально, так как она реактивная -->
<code>{@html parsedDate}</code>
Симулировать ввод пользователя в поле просто. Проблема была в чтении export let date: string снаружи. Поэтому я создал компонент-обёртку с переменной (dateFromDateEditor), которую устанавливает DateEditor и привязывает к <input> в обёртке:
<script lang="ts">
import DateEditor from "../../src/ui/DateEditor.svelte";
let dateFromDateEditor: string = "";
</script>
<DateEditor id="due" bind:date="{dateFromDateEditor}" forwardOnly="{true}" />
<input bind:value="{dateFromDateEditor}" id="dueDateFromDateEditor" />
Теперь можно рендерить обёртку в тестах и проверять начальное значение через input обёртки:
function renderDateEditorWrapper() {
const { container } = render(DateEditorWrapper, {});
return container;
}
function getAndCheckRenderedElement<T>(
container: HTMLElement,
elementId: string,
) {
const element = container.ownerDocument.getElementById(elementId) as T;
expect(() => element).toBeTruthy();
return element;
}
describe("date editor tests", () => {
it("should test initial state", async () => {
const container = renderDateEditorWrapper();
const dueDateFromDateEditorInput =
getAndCheckRenderedElement<HTMLInputElement>(
container,
"dueDateFromDateEditor",
);
expect(dueDateFromDateEditorInput.value).toEqual("");
});
});
Следующий тест — пользователь вводит что-то в <input> компонента DateEditor, и смотрим результат через обёртку:
it("should replace an empty date field with typed abbreviation", async () => {
const container = renderDateEditorWrapper();
const dueDateInput = getAndCheckRenderedElement<HTMLInputElement>(
container,
"due",
);
await fireEvent.input(dueDateInput, { target: { value: "tm " } });
// Несмотря на введенный текст 'tm ', значение в поле
// преобразует реактивность. Именно это нужно тестировать!
expect(dueDateInput.value).toEqual("tomorrow");
const dueDateFromDateEditorInput =
getAndCheckRenderedElement<HTMLInputElement>(
container,
"dueDateFromDateEditor",
);
expect(dueDateFromDateEditorInput.value).toEqual("tomorrow");
});
В последнем expect() — наше реактивное значение под тестом. Осталось отрефакторить оба теста, вынеся тестирующую функцию, чтобы показать намерение и сохранить читаемость через месяц:
async function userTyped(
container: HTMLElement,
elementId: string,
text: string,
) {
const input = getAndCheckRenderedElement<HTMLInputElement>(
container,
elementId,
);
await fireEvent.input(input, { target: { value: text } });
}
function testInputValue(
container: HTMLElement,
inputId: string,
expectedText: string,
) {
const input = getAndCheckRenderedElement<HTMLInputElement>(
container,
inputId,
);
expect(input.value).toEqual(expectedText);
}
describe("date editor tests", () => {
it("should test initial state", async () => {
const container = renderDateEditorWrapper();
testInputValue(container, "dueDateFromDateEditor", "");
});
it("should replace an empty date field with typed abbreviation", async () => {
const container = renderDateEditorWrapper();
await userTyped(container, "due", "tm ");
testInputValue(container, "due", "tomorrow");
testInputValue(container, "dueDateFromDateEditor", "tomorrow");
});
});
Появились выразительные тесты, покрывающие поведение переменной export let date: string; в компоненте. Но реактивная часть также меняет parsedDate:
<script lang="ts">
// <...>
let parsedDate: string;
$: {
date = doAutocomplete(date);
parsedDate = parseTypedDateForDisplayUsingFutureDate(id, date, forwardOnly);
}
</script>
<!-- <...> -->
<code>{@html parsedDate}</code>
Чтобы это протестировать, я предложил экспортировать эту переменную:
<script lang="ts">
// Только для целей тестирования
export let parsedDate: string = "";
// <...>
</script>
Теперь её можно использовать в обёртке так же, как раньше — привязав к <input>:
<script lang="ts">
import DateEditor from "../../src/ui/DateEditor.svelte";
let dateFromDateEditor: string = "";
let parsedDateFromDateEditor: string = "";
</script>
<DateEditor
id="due"
bind:date="{dateFromDateEditor}"
bind:parsedDate="{parsedDateFromDateEditor}"
/>
<input bind:value="{dateFromDateEditor}" id="dueDateFromDateEditor" />
<input bind:value="{parsedDateFromDateEditor}" id="parsedDateFromDateEditor" />
В тестах можно переиспользовать нашу тестирующую функцию с ID нового <input> — и получаем довольно надежный паттерн тестирования:
describe("date editor tests", () => {
// <...>
it("should replace an empty date field with typed abbreviation", async () => {
const container = renderDateEditorWrapper();
await userTyped(container, "due", "tm ");
testInputValue(container, "due", "tomorrow");
// Предположим, что tomorrow — это 2024-10-01
testInputValue(container, "parsedDateFromDateEditor", "2024-10-01");
testInputValue(container, "dueDateFromDateEditor", "tomorrow");
});
});
На этом этапе осталось разработать различные тест-кейсы, используя этот паттерн, чтобы покрыть всё поведение компонента (а не только функции в реактивной части). Для этого нужно было копировать 9 строк из предыдущего сниппета, поэтому была вынесена еще одна функция:
async function testTypingInput({
userTyped,
// Left и right здесь — из-за расположения компонентов
expectedLeftText,
expectedRightText,
expectedReturnedDate,
}: {
userTyped: string;
expectedLeftText: string;
expectedRightText: string;
expectedReturnedDate: string;
}) {
const container = renderDateEditorWrapper({ forwardOnly });
await userTyped(container, "due", "tm ");
testInputValue(container, "due", "tomorrow");
testInputValue(container, "parsedDateFromDateEditor", "2024-10-01");
testInputValue(container, "dueDateFromDateEditor", "tomorrow");
}
describe("date editor tests", () => {
// <...>
it("should replace an empty date field with typed abbreviation", async () => {
await testTypingInput({
userTyped: "tm ",
expectedLeftText: "tomorrow",
expectedRightText: "2024-10-01",
expectedReturnedDate: "tomorrow",
});
});
});
Наконец мы были готовы покрыть все тест-кейсы, внедряя ошибки в код и проверяя, упадут ли тесты. И в процессе заметили, что при внедрении ошибки в переменную forwardOnly никакие тесты не падали — значит, какая-то часть поведения не была покрыта:
<script lang="ts">
// <...>
$: {
date = doAutocomplete(date);
parsedDate = parseTypedDateForDisplayUsingFutureDate(
id,
date,
/* внедрение ошибки: !forwardOnly */ forwardOnly,
);
}
</script>
Чтобы решить это, нужно было добавить export‘ированную переменную в обёртку и передавать её из тестов:
<script lang="ts">
import DateEditor from "../../src/ui/DateEditor.svelte";
// Эта переменная
export let forwardOnly: boolean;
let dateFromDateEditor: string = "";
let parsedDateFromDateEditor: string = "";
</script>
Чтобы использовать её в тестах, нужно передавать через параметры компонента при рендеринге — вот обновленные функции рендеринга и тестирования:
function renderDateEditorWrapper(componentOptions: { forwardOnly: boolean }) {
const { container } = render(DateEditorWrapper, componentOptions);
return container;
}
async function testTypingInput(
{
userTyped,
expectedLeftText,
expectedRightText,
expectedReturnedDate,
}: {
userTyped: string;
expectedLeftText: string;
expectedRightText: string;
expectedReturnedDate: string;
},
{ forwardOnly }: { forwardOnly: boolean } = { forwardOnly: true },
) {
const container = renderDateEditorWrapper({ forwardOnly });
// <...>
}
Значение по умолчанию берется из предыдущей версии обёртки и объявляется только один раз — в тестирующей функции, не в renderer’е. Поле forwardOnly вынесено в отдельный параметр для разделения ответственности (взаимодействие пользователя и конфигурация компонента). Добавить тест с forwardOnly: false стало совсем просто:
describe("date editor tests", () => {
// <...>
it("should select a forward date", async () => {
await testTypingInput(
{
userTyped: "friday",
expectedLeftText: "friday",
expectedRightText: "2024-10-04",
expectedReturnedDate: "friday",
},
{
// Явно прописано в тесте, чтобы показать отличие от следующего
forwardOnly: true,
},
);
});
it("should select a backward/earlier date", async () => {
await testTypingInput(
{
userTyped: "friday",
expectedLeftText: "friday",
expectedRightText: "2024-09-27",
expectedReturnedDate: "friday",
},
{
forwardOnly: false,
},
);
});
});
Всё это было сделано в парном программировании чуть больше чем за полтора часа. После этого разработка новых фич упростилось — есть «фреймворк» для создания тестов и быстрая обратная связь о поведении компонента под тестами. Разработка нового поведения — следующий шаг.
Что можно улучшить? Очевидно, что специализированные matcher’ы в Jest лучше тестирующих функций с точки зрения DX, но функции проще, реализовать их быстрее — так что matcher’ы могут стать улучшением в будущем.
Полный объем изменений можно найти в PR: refactor: test `DateEditor` svelte component by ilandikov · Pull Request #3164 · obsidian-tasks-group/obsidian-tasks · GitHub.