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