Хабр, привет. Меня зовут Рамиль Шайбаков, я фронтенд‑разработчик в СберЗдоровье. Последние несколько лет я часто собеседую кандидатов на позицию frontend‑разработчика в нашу компанию и заметил одну закономерность — у большинства специалистов нет опыта в тестировании. Причем знаниями о unit/интеграционных/e2e‑тестах, пирамиде тестирования, красно‑зелёном рефакторинге, TDD и BDD, скриншот‑тестировании и других техниках не могут похвастаться как новоиспеченные фронтенды, так и более подготовленные специалисты. Причины и аргументы у всех разные, но итог один — фронтендеры часто не делают тесты.

Я решил исправить эту ситуацию и подготовил небольшой гайд, который поможет фронтенд‑разработчикам внедрить тестирование в своей компании и сделать в нем первые шаги.

Поехали.

Аргументация бизнес‑ценности

Для тестирования нужны ресурсы, а без одобрения бизнес‑команды их часто не получить. Поэтому первое, с чем сталкивается фронтенд‑разработчик — необходимость доказать бизнесу пользу от внедрения тестов. Аргументов может быть много, например:

  • Снижение рисков появления багов. С помощью тестов можно выявить ошибки на этапе разработки и исключить глобальные сбои в продакшене.

  • Сокращение time to market. Если устранять баги в момент их появления, продукт можно быстрее выкатить на рынок и уделять больше времени бизнес‑фичам.

  • Ускорение онбординга. Тесты включают описание пользовательских сценариев, которые упрощают понимание кода для новичков в проекте.

Если этих аргументов недостаточно, тесты можно провести в экспериментальном режиме с ограниченным набором ресурсов и выбрать для них несколько метрик, которые потенциально можно улучшить. Например:

  • количество ошибок в системе мониторинга (Sentry, Bugsnag, NewRelic и т. д.);

  • число инцидентов после релизов;

  • количество заведенных багов в трекере задач;

  • объем реализованных бизнес‑фичей;

  • время разработки бизнес‑фичей (time to market).

Даже небольшого эксперимента хватит, чтобы проверить эффективность тестов и рентабельность их внедрения. Так, даже если один из показателей получится существенно улучшить, у вас будет больше аргументов перед руководством и больше шансов внедрить тестирование на постоянной основе.

Сразу отмечу, что на старте проекта заложить ресурсы для тестов проще, поэтому доказывать их пользу желательно на начальных этапах разработки. Но если проект уже есть, а тестов нет, для покрытия тестами кодовой базы можно задействовать время на избавление кода от легаси или технический долг — в хорошо организованных проектах оно всегда заложено в процесс разработки. Если у вас эта практика еще не внедрена — самое время это сделать.

Выбор стека

Следующий этап — выбор инструмента. Вариантов библиотек много, отличий между ними тоже, поэтому выбор зачастую делают, исходя из задач тестирования и персональных предпочтений. Вместе с тем, я рекомендую Testing Library + Jest. Причин несколько:

  • расширенная совместимость — подходит под любые современные фреймворки (React, Vue, Svelte, Angular и другие);

  • удобная и подробная документация;

  • простое и понятное API;

  • большое комьюнити, которое помогает найти ответы на сложные вопросы;

  • поддержка расширений для инструментов E2E‑тестирования (Cypress, TestCafe, WebDriverIO и другие).

Ключевая особенность библиотеки — подход тестирования, при котором разработчик может концентрироваться на функциональности пользовательского интерфейса, а не на внутренней реализации компонентов.

Возьмем пример из медтеха. Пусть это будет фильтр — контролируемый компонент, который фильтрует врачей по специальности.

Посмотреть тестирование фильтров для врачей
const allSpecialties = ["Терапевт", "Гастроэнтеролог", "Эндокринолог"];

// Компонент фильтров
export default function SpecialtyFilter({
  specialties = [],
  onChange = () => {}
}) {
  const handleSpecialtiesChange = (speciality) => {
    const newSpecialties = specialties.includes(speciality)
    ? specialties.filter((item) => item !== speciality)
    : [...specialties, speciality];

    onChange(newSpecialties);
  };

  return (
    <div>
      {allSpecialties.map((specialty) => (
        <label key={specialty}>
          <input
            type="checkbox"
            value={specialty}
            checked={specialties.includes(specialty)}
            onChange={() => handleSpecialtiesChange(specialty)}
          />

          {specialty}
        </label>
      ))}
    </div>
  );
}

// Тесты
describe("SpecialtyFilter", () => {
  test("должен быть отображен не выбранный чекбокс специальности", () => {
    render(<SpecialtyFilter />);

    expect(screen.getByLabelText("Терапевт")).not.toBeChecked();
  });

  test("должен быть отображен выбранный чекбокс специальности", () => {
    render(<SpecialtyFilter specialties={["Терапевт"]} />);

    expect(screen.getByLabelText("Терапевт")).toBeChecked();
  });

  test("должно быть вызвано событие с выбранным значением специальности", () => {
    const onChange = jest.fn();
    render(<SpecialtyFilter onChange={onChange} />);

    fireEvent.click(screen.getByLabelText("Терапевт"));
    expect(onChange).toBeCalledWith(["Терапевт"]);
  });

  test("должно быть вызвано событие с не выбранным значением специальности", () => {
    const onChange = jest.fn();
    render(<SpecialtyFilter specialties={["Терапевт"]} onChange={onChange} />);

    fireEvent.click(screen.getByLabelText("Терапевт"));
    expect(onChange).toBeCalledWith([]);
  });
});

Из примера видно, что в части тестов сделан упор на проверку обратного вызова onChange, что является внутренней реализацией. Пользователю о нем ничего не известно. Но это не дает полной уверенности в корректности работы интерфейса — алгоритм может сломаться уже вне рамок этого компонента.

Давайте попробуем пересмотреть наши тесты и проведем проверку на более верхнем уровне. Для этого нам понадобиться компонент, в котором наши фильтры используются и влияют на интерфейс.

Посмотреть тестирование списка врачей с фильтром
// Мокаем список врачей для примера (в реальной жизни получаем из API)
const allDoctors = [
  {
    id: 1,
    name: "Иванов Григорий Александрович",
    specialty: "Терапевт"
  },
  {
    id: 2,
    name: "Петрова Валерия Игоревна",
    specialty: "Терапевт"
  },
  {
    id: 3,
    name: "Сидорова Ирина Владимировна",
    specialty: "Терапевт"
  },
  {
    id: 4,
    name: "Смирнов Сергей Викторович",
    specialty: "Гастроэнтеролог"
  },
  {
    id: 5,
    name: "Сорокин Вячеслав Георгиевич",
    specialty: "Эндокринолог"
  }
];

// Компонент раздела со списком врачей
export default function DoctorsPage({ specialties = [] }) {
	// Фильтр
  const [activeSpecialties, setActiveSpecialties] = useState(specialties);
  const handleSpecialtiesChange = (value) => {
    setActiveSpecialties(value);
  };

  const doctors = activeSpecialties.length
  ? allDoctors.filter((doctor) => activeSpecialties.includes(doctor.specialty))
  : allDoctors

  return (
    <div>
      <SpecialtyFilter
        specialties={activeSpecialties}
        onChange={handleSpecialtiesChange}
      />

      <DoctorsList doctors={doctors} />
    </div>
  );
}

// Компонент списка врачей
export default function DoctorsList({ doctors = [] }) {
  return (
    <ul>
      {doctors.map((doctor) => (
        <li data-testid="doctor" key={doctor.id}>
          <span>{doctor.name}</span> - <span>{doctor.specialty}</span>
        </li>
      ))}
    </ul>
  );
}

// Тесты
describe("DoctorsPage", () => {
  describe("когда пользователь выбрал специальность в фильтре", () => {
    test("чекбокс специальности выбран", () => {
      render(<DoctorsPage />);

      const checkbox = screen.getByLabelText("Терапевт");
      fireEvent.click(checkbox);

      expect(checkbox).toBeChecked();
    });

    test("врачи выбранной специальности отображены", () => {
      render(<DoctorsPage />);

      fireEvent.click(screen.getByLabelText("Терапевт"));

      const doctors = screen.getAllByTestId("doctor");
      expect(doctors.length).toBe(3);
      doctors.forEach((doctorEl) => {
        expect(doctorEl).toHaveTextContent("Терапевт");
      });
    });
  });

  describe("когда пользователь отменил выбор специальности в фильтре", () => {
    test("чекбокс специальности не выбран", () => {
      render(<DoctorsPage specialties={["Терапевт"]} />);

      const checkbox = screen.getByLabelText("Терапевт");
      fireEvent.click(checkbox);

      expect(checkbox).not.toBeChecked();
    });

    test("все врачи отображены", () => {
      render(<DoctorsPage specialties={["Терапевт"]} />);

      fireEvent.click(screen.getByLabelText("Терапевт"));
      expect(screen.getAllByTestId("doctor").length).toBe(5);
    });
  });
});

Теперь мы видим, что в тестах мы проверяем именно поведение пользователя. Это дает больше гарантий в корректности работы интерфейса.

Подход позволяет не тратить время на отладку тестов после рефакторинга — рефакторинг зачастую меняет не поведение компонентов, а их реализацию.

Выбор компонентов для тестирования

При выборе компонента многие идут по пути увеличения сложности — от маленьких и простых к большим и сложным. Такой подход ошибочный — маленькие компоненты часто имеют мало пользовательских сценариев и их отдельная проверка не дает уверенности в корректной работе всего приложения. При этом тесты маленьких компонентов отнимают у нас драгоценное время. Поэтому лучше изначально концентрироваться на больших компонентах, которые отражают некий смысловой блок. Таким может быть:

  • корзина, карточка товаров, списки категорий товаров в интернет‑магазинах;

  • раздел статистики, таблица с фильтрами и сортировками, форма настройки профиля в админке;

  • плеер, список плейлистов, блок рекомендаций в стриминговых сервисах;

  • список врачей и клиник, профиль врача и клиники, форма записи, отзывы в медтех сервисах (наш случай);

  • форма авторизации, шапка, футер, сайдбар и другие общие блоки.

По сути в качестве большого компонента можно рассматривать любую часть приложения, которая имеет целый набор пользовательских кейсов, но автономна от других частей.

И не забудьте выставлять приоритеты вашим компонентам. Всегда задавайте себе вопрос: «Что было бы хуже всего сломать в этом приложении?».

Описание тестов

Подходов при описании тестов много — у каждого есть нюансы, но все они имеют право на жизнь. Я расскажу о подходе, который мы в компании выработали в результате длительной работы с тестами — что‑то заимствовали у других, где‑то подсмотрели в BDD, что‑то придумали сами.

Итак. Чтобы тесты были более читаемые, их описанию надо уделять большое внимание. Поскольку мы отталкиваемся от пользовательских сценариев, описание тестов должно их отражать. В первую очередь надо обратиться к тестируемому компоненту и выделить все его состояния, а также их комбинации, в том числе его дочерние элементы.

Давайте добавим в наш компонент DoctorsPage больше логики. Пусть, помимо фильтрации врачей, у нас будет сортировка.

Посмотреть код компонента списка врачей с фильтром и сортировкой
// Добавим в список дополнительные поля rating и price для сортировки
const allDoctors = [
  {
    id: 1,
    name: "Иванов Григорий Александрович",
    specialty: "Терапевт",
    rating: 5,
    price: 3000
  },
  {
    id: 2,
    name: "Петрова Валерия Игоревна",
    specialty: "Терапевт",
    rating: 4.5,
    price: 2500
  },
  // ...
];

export default function DoctorsPage({ specialties = [], sort = 'specialty' }) {
  // Фильтр
  const [activeSpecialties, setActiveSpecialties] = useState(specialties);
  const handleSpecialtiesChange = (value) => {
    setActiveSpecialties(value);
  };

  // Сортировка
  const [activeSort, setActiveSort] = useState(sort);
  const handleSortChange = (value) => {
    setActiveSort(value);
  };

  const doctors = activeSpecialties.length
  ? allDoctors.filter((doctor) => activeSpecialties.includes(doctor.specialty))
  : allDoctors;

  if (activeSort !== sort) {
    // Опустим имплементацию функции сортировки, она здесь не важна
    sortDoctors(doctors, activeSort);
  }

  return (
    <div>
      <SpecialtyFilter
        specialties={activeSpecialties}
        onChange={handleSpecialtiesChange}
      />

      <DoctorsSorting
        sort={activeSort}
        onChange={handleSortChange}
      />

      <DoctorsList doctors={doctors} />
    </div>
  );
}

В этот раз мы можем отметить следующие состояния интерфейса:

  • Пользователь зашел на страницу и увидел весь список врачей.

  • Пользователь выбрал фильтр и увидел отфильтрованный список врачей.

  • Пользователь выбрал разные сортировки и увидел отсортированный список врачей.

  • Пользователь выбрал фильтр и разные сортировки, увидел отфильтрованный и отсортированный список врачей.

  • Пользователь отменил фильтр и увидел весь список врачей.

  • Пользователь отменил сортировку и увидел весь список врачей.

После этого можно приступать к составлению наброска будущего теста.

Посмотреть тестирование списка врачей с фильтрами и сортировками
describe("DoctorsPage", () => {
  // Список врачей без фильтра и сортировки (по умолчанию)
  test("чекбоксы специальности не выбраны", () => {});
  test("все врачи отображены", () => {});
  test("врачи отсортированы по специальности", () => {});

  // Список врачей с выбранным фильтром
  describe("когда пользователь выбрал специальность в фильтре", () => {
    test("чекбокс специальности выбран", () => {});
    test("врачи выбранной специальности отображены", () => {});
    test("врачи отсортированы по специальности", () => {});

    // Список врачей с выбранным фильтром и разными сортировками
    describe("когда пользователь выбрал сортировку по рейтингу", () => {
      test("чекбокс специальности выбран", () => {});
      test("врачи выбранной специальности отображены", () => {});
      test("врачи отсортированы по рейтингу", () => {});
    });

    describe("когда пользователь выбрал сортировку по цене", () => {
      test("чекбокс специальности выбран", () => {});
      test("врачи выбранной специальности отображены", () => {});
      test("врачи отсортированы по цене", () => {});
    });

    // Список врачей без фильтра (после отмены фильтра)
    describe("когда пользователь отменил выбор специальности в фильтре", () => {
      test("чекбокс специальности не выбран", () => {});
      test("все врачи отображены", () => {});
      test("врачи отсортированы по специальности", () => {});
    });
  });

  // Список врачей с разными сортировками
  describe("когда пользователь выбрал сортировку по рейтингу", () => {
    test("все врачи", () => {});
    test("все врачи отсортированы по рейтингу", () => {});

    // Список врачей c сортировкой по умолчанию (после отмены сортировки)
    describe("когда пользователь отменил сортировку", () =>  {
      test("все врачи отображены", () => {});
      test("врачи отсортированы по специальности", () => {});
    });
  });

  describe("когда пользователь выбрал сортировку по цене", () => {
    test("врачи выбранной специальности отображены", () => {});
    test("врачи отсортированы по цене", () => {});
  });
});

Первому describe мы даём название нашего смыслового блока. В нем могут содержаться тесты первоначального состояния нашего интерфейса, когда пользователь только его увидел.

Дальше идёт список вложенных describe, которые уже описывают различные состояния родительского компонента. Причём вложенность может быть многоуровневая — за счет этого мы можем проверить все комбинации состояний. Ну и в конце каждой вложенности мы имеем наши тесты, которые содержат все проверки ожидаемых результатов.

При таком подходе мы можем быть уверены, что проверим все возможные варианты сценариев пользователя.

Последовательность поведения. В некоторых случаях стоит проверить и последовательность действий пользователя. Например:

— пользователь выбрал сначала фильтр, а потом сортировку и увидел отфильтрованный и отсортированный список врачей;
— пользователь выбрал сначала сортировку, а потом фильтр и увидел отфильтрованный и отсортированный список врачей.

В нашем случае это не критично и мы это опустили.

В такой структуре очень легко масштабировать тесты, когда появляются новые состояния. Для этого нам необходимо создать новый describe и скопировать в него уже существующие проверки, а после доработать их под комбинацию с новым состоянием. Это так же дает возможность выносить общую часть тестов наверх или в хуки beforeEach/afterEach, beforeAll/afterAll.

Чистота тестов. Чтобы тесты проходили без побочных эффектов, надо позаботиться об их «стерильности». Рендер компонентов и инициализация стора может быть вынесена в хук beforeEach, но только не в beforeAll. Конечно, из‑за этого тесты будут занимать больше времени, но это даст гарантии, что на них не влияют соседние тесты.

Плюс еще и в том, что все описанные пользовательские кейсы всегда доступны — это будет особенно полезно, когда надо разбираться в функциональности, коде другого разработчика или погружаться в проект во время онбординга.

Отдельно отмечу — как видно на примере, мы используем описание на русском языке. Нам так удобнее. Но это не обязательная практика — писать можно на любом языке.

Определение метрик

Разработчикам в первую очередь важен процент покрытия кода тестами. В Jest из коробки есть инструмент для формирования отчетов по покрытию в разных форматах. С его помощью можно отслеживать ряд метрик:

  • Statements — количество выполненных выражений;

  • Branches — количество выполненных ответвлений в коде, таких как if, else, switch;

  • Functions — количество вызванных функций;

  • Lines — количество выполненных строк в тестируемом коде.

Отчёт по coverage в Jest
Отчёт по coverage в Jest

Но стоит отметить, что ориентироваться только на эти метрики нельзя — компоненты могут быть покрыты на 100%, но даже в таком случае не задействовать все пользовательские сценарии. Поэтому важно учитывать не только процент покрытия, но и изучать комбинации состояний, которые содержит компонент и его потомки.

Вместе с тем, эти метрики не помогут оценить эффективность тестирования для бизнеса. Поэтому для аргументации бизнес‑ценностей лучше опираться на показатели, о которых я писал в начале.

Исключения

В тестах фронтенда редко встречаются строгие правила — каждый проект уникален и может иметь факторы, влияющие на процесс тестирования. Поэтому есть и исключения. Например, иногда надо использовать разные API (iframe, web workers, web RTC, сторонние интеграции), но нужна уверенность в корректности их работы. В таких случаях нет ничего плохого в проверке взаимодействия с кодом в обход правил тестирования пользовательского интерфейса.

У нас тоже есть кейс с исключением — после рефакторинга или релиза новой функциональности у наших аналитиков переставала собираться часть событий, отправляемых из приложения. Поэтому мы сделали исключение и тестируем вызов функций по отправке данных.

describe("когда пользователь нажал на кнопку вызова врача на дом", () => {
  test("событие отправлено в аналитику", async () => {
    const mockClickButtonEvent = jest.mocked(clickButtonEvent);
    mockClickButtonEvent.mockClear();

    render(<DoctorForm />);

    fireEvent.click(screen.getByTestId("deparure-button"));

    expect(mockClickButtonEvent.mock.lastCall[0]).toEqual({
      /* payload */
    });
  });
});

Вместо выводов

Фронтенд‑разработчикам не надо бояться тестов — они должны войти в культуру, как это сделали ESLint и TypeScript. Описанные мной рекомендации и примеры — база, которой должно хватить, чтобы сделать первые шаги в тестировании и понять, что это не сложно. Если хотите больше погрузиться в технические моменты — рекомендую изучить курс Кента Додса.

Буду признателен за обратную связь. Ваши комментарии и вопросы помогут мне понять, стоит ли развивать эту тему в будущем. Всем добра!

Комментарии (0)