Семантичний підхід до тестування UI

Євген Орлов

  • У фронтенді з 2017, останній рік з Levi9 👨‍💻
  • Іноді викладаю в Kottans
  • Міжнародний e-commerce проект 🌍

  • Кілька десятків розробників в кількох країнах 👩🏽‍💻👨🏻‍💻👨🏼‍💻

  • Фронтенд розбитий на кілька репозиторіїв, які ділять між собою дизайн систему у вигляді внутрішньої UI бібліотеки 💅

  • Код нерівномірно, але покритий тестами (Jest, Puppeteer, Enzyme) 🥽

Дано:

  • Тестування UI-компонентів (на прикладі React, Enzyme, Testing Library)

  • Спроба переосмислити і актуалізувати best practices

  • Спроба переконати вас в доцільності такого переосмислення на прикладі case study

Говоритимемо про

  • базові штуки (що таке Jest? як працюють тести?)

  • end-to-end тестування

  • істини в останній інстанції та інші silver bullet solutions

А про це не будемо 😉

export class FAQItem extends React.Component {
  state = {
    open: false,
    answerHeight: 0,
  };
  answerRef = React.createRef();

  toggleAnswer = () => {
    this.setState({
      answerHeight: this.answerRef.current ? answerRef.current.scrollHeight : 0,
      open: !this.state.open,
    });
  };

  render() {
    const { question, answer } = this.props;
    const { open, answerHeight } = this.state;
    return (
      <Wrapper>
        <Toggle onClick={this.toggleAnswer}>
          <Paragraph fontWeight="bold">{question}</Paragraph>
          <PlusIcon open={open} />
        </Toggle>
        <AnswerWrapper
          innerRef={this.answerRef}
          open={open}
          height={answerHeight}
        >
          <Answer open={open}>{answer}</Answer>
        </AnswerWrapper>
      </Wrapper>
    );
  }
}
import { shallow } from "enzyme";

describe("FAQItem", () => {
  test("renders according to snapshot", () => {
    expect(
      shallow(<FAQItem question="Question?" answer="Answer" />)
    ).toMatchSnapshot();
  });

  test("toggles", () => {
    const instance = shallow(
      <FAQItem question="Question?" answer="Answer" />
    ).instance();

    instance.toggleAnswer();
    expect(instance.state.open).toBe(true);
    instance.toggleAnswer();
    expect(instance.state.open).toBe(false);
  });
});
export class FAQItem extends React.Component {
  state = {
    isOpen: false,
    answerHeight: 0,
  };
  answerRef = React.createRef();

  toggleAnswer = () => {
    this.setState({
      answerHeight: this.answerRef.current ? answerRef.current.scrollHeight : 0,
      isOpen: !this.state.isOpen,
    });
  };

  render() {
    const { question, answer } = this.props;
    const { isOpen, answerHeight } = this.state;
    return (
      <Wrapper>
        <Toggle onClick={this.toggleAnswer}>
          <Paragraph fontWeight="bold">{question}</Paragraph>
          <PlusIcon isOpen={isOpen} />
        </Toggle>
        <AnswerWrapper
          innerRef={this.answerRef}
          isOpen={isOpen}
          height={answerHeight}
        >
          <Answer isOpen={isOpen}>{answer}</Answer>
        </AnswerWrapper>
      </Wrapper>
    );
  }
}
import { shallow } from "enzyme";

describe("FAQItem", () => {
  test("renders according to snapshot", () => {
    expect(
      shallow(<FAQItem question="Question?" answer="Answer" />)
    ).toMatchSnapshot();
  });

  test("toggles", () => {
    const instance = shallow(
      <FAQItem question="Question?" answer="Answer" />
    ).instance();

    instance.toggleAnswer();
    expect(instance.state.isOpen).toBe(true);
    instance.toggleAnswer();
    expect(instance.state.isOpen).toBe(false);
  });
});
export const FAQItem = ({ question, answer }) => {
  const [open, setOpen] = useState(false);
  const [answerHeight, setAnswerHeight] = useState(0);
  const answerRef = useRef();

  const toggleAnswer = () => {
    setOpen(!open);
    setAnswerHeight(answerRef.current ? answerRef.current.scrollHeight : 0);
  };

  return (
    <Wrapper>
      <Toggle onClick={toggleAnswer}>
        <Paragraph fontWeight="bold">{question}</Paragraph>
        <PlusIcon open={open} />
      </Toggle>
      <AnswerWrapper innerRef={answerRef} open={open} height={answerHeight}>
        <Answer open={open}>{answer}</Answer>
      </AnswerWrapper>
    </Wrapper>
  );
};
import { shallow } from "enzyme";


describe("FAQItem", () => {
  // 🟡 потребує оновлення
  test("renders according to snapshot", () => {
    expect(
      shallow(<FAQItem question="Question?" answer="Answer" />)
    ).toMatchSnapshot();
  });

  // 🔴 функціональні компоненти не працюють з .instance() взагалі
  test("toggles", () => {
    const instance = shallow(
      <FAQItem question="Question?" answer="Answer" />
    ).instance();

    instance.toggleAnswer();
    expect(instance.state.isOpen).toBe(true);
    instance.toggleAnswer();
    expect(instance.state.isOpen).toBe(false);
  });
});

image credit: https://nickjanetakis.com/blog/have-you-hit-the-point-of-diminishing-returns-as-a-developer

image credit: https://www.sketchplanations.com/post/167369765942/goodharts-law-when-a-measure-becomes-a-target

Міряємо покриття

Відсотками від LoC - погана метрика.
Краще юзкейсами.

Отже, хороші практики:

1. No false positives — якщо функціонал зламався вглибині компонента, я хочу про це знати

2. No false negatives — якщо все працює, тест не має падати (інакше розробник перестає довіряти тестам)

import { shallow } from "enzyme";

describe("FAQItem", () => {
  test("renders according to snapshot", () => {
    expect(
      shallow(<FAQItem question="Question?" answer="Answer" />)
    ).toMatchSnapshot();
  });

  test("toggles", () => {
    const instance = shallow(
      <FAQItem question="Question?" answer="Answer" />
    ).instance();

    instance.toggleAnswer();
    expect(instance.state.isOpen).toBe(true);
    instance.toggleAnswer();
    expect(instance.state.isOpen).toBe(false);
  });
});
import { shallow } from "enzyme";

describe("FAQItem", () => {
  test("renders according to snapshot", () => {
    expect(
      shallow(<FAQItem question="Question?" answer="Answer" />)
    ).toMatchSnapshot();
  });

  test("toggles", () => {
    const instance = shallow(
      <FAQItem question="Question?" answer="Answer" />
    ).instance();

    instance.toggleAnswer();
    expect(instance.state.isOpen).toBe(true);
    instance.toggleAnswer();
    expect(instance.state.isOpen).toBe(false);
  });
});
const wrapper = shallow(<FAQItem question="Question?" answer="Answer" /> 
wrapper.find('button').simulate('click');
// емм, "visible"? "height"? 🤔
export const FAQItem = ({ question, answer }) => {
  const [open, setOpen] = useState(false);
  const [answerHeight, setAnswerHeight] = useState(0);
  const answerRef = useRef();

  const toggleAnswer = () => {
    setOpen(!open);
    setAnswerHeight(answerRef.current ? answerRef.current.scrollHeight : 0);
  };

  return (
    <Wrapper>
      <Toggle
        onClick={toggleAnswer}
        aria-expanded={isOpen}
        aria-label="Expand content"
      >
        <Paragraph fontWeight="bold">{question}</Paragraph>
        <PlusIcon isOpen={isOpen} />
      </Toggle>
      <AnswerWrapper
        innerRef={answerRef}
        isOpen={isOpen}
        height={answerHeight}
        aria-hidden={!isOpen}
        aria-label="Expanded content"
      >
        <Answer isOpen={isOpen}>{answer}</Answer>
      </AnswerWrapper>
    </Wrapper>
  );
};
test("expands on click", () => {
  const wrapper = mount(<FAQItem question="Question?" answer="Answer" />);

  expect(
    wrapper.find(`[aria-label="Expand content"]`).first().prop("aria-expanded")
  ).toBe(false);
  expect(
    wrapper.find(`[aria-label="Expanded content"]`).first().prop("aria-hidden")
  ).toBe(true);

  wrapper.find(`[aria-label="Expand content"]`).first().simulate("click");

  expect(
    wrapper.find(`[aria-label="Expand content"]`).first().prop("aria-expanded")
  ).toBe(true);
  expect(
    wrapper.find(`[aria-label="Expanded content"]`).first().prop("aria-hidden")
  ).toBe(false);
});

DOM API

Enzyme

being a powerful,

unopinionated tool

UI = 𝒇(state)

/**
 * Find first node with aria-label that matches query.
 */
export const findByAriaLabel = (wrapper: ReactWrapper, ariaLabel: string) =>
  wrapper.find(`[aria-label="${ariaLabel}"]`).first();

/**
 * Simulate click on the element. If selector is passed, simulate click on the first element child that matches selector.
 */
export const click = (wrapper: ReactWrapper, selector?: string) => {
  if (selector) {
    wrapper.find(selector).first().simulate("click");
  } else {
    wrapper.simulate("click");
  }
};
test("expands on click", () => {
  const wrapper = mount(<FAQItem question="Question?" answer="Answer" />);
  
  expect(findByAriaLabel(wrapper, "Expand content").prop("aria-expanded")).toBe(
    false
  );
  expect(findByAriaLabel(wrapper, "Expanded content").prop("aria-hidden")).toBe(
    true
  );

  click(wrapper, `[aria-label="Expand content"]`);

  expect(findByAriaLabel(wrapper, "Expand content").prop("aria-expanded")).toBe(
    true
  );
  expect(findByAriaLabel(wrapper, "Expanded content").prop("aria-hidden")).toBe(
    false
  );
});

Мінуси:

  1. треба підтримувати
  2. треба слідкувати, щоб всі дотримувались best practices

API surface area

 

те, скільки API забороняє робити, не менш важливе ніж те, скільки він дозволяє

Testing Library

  • забороняє доступ до state і методів компонента
  • забороняє shallow rendering
  • пріоритизує інтеграційні тести над юніт-тестами
  • Фокус API селекторів — на семантиці

Словом, не дає писати 💩

Селектори:

  • ByLabelText
  • ByPlaceholderText
  • ByText
  • ByAltText
  • ByTitle
  • ByDisplayValue
  • ByRole
  • ByTestId

jest-dom

бібліотека-компаньйон з кастомними метчерами

  • toBeDisabled

  • toBeEnabled

  • toBeEmpty

  • toBeInTheDocument

  • toBeInvalid

  • toBeRequired

  • toBeValid

  • toBeVisible

  • toContainElement

  • toContainHTML

  • toHaveAttribute

  • toHaveClass

  • toHaveFocus

  • toHaveFormValues

  • toHaveStyle

  • toHaveTextContent

  • toHaveValue

  • toHaveDisplayValue

  • toBeChecked

  • toBePartiallyChecked

  • toHaveDescription

import { render, fireEvent } from "@testing-library/react";
import '@testing-library/jest-dom';

describe('FAQItem', () => {
  it('expands when clicked', () => {
    const { getByLabelText } = render(
      <FAQItem question="Question?" answer="Answer" />
    );
    const button = getByLabelText(/expand content/i);
    const content = getByLabelText(/expanded content/i);
    
    expect(button).toHaveAttribute('aria-expanded', 'false');
    expect(content).toHaveAttribute('aria-hidden', 'true');
    
    fireEvent.click(button);
    
    expect(button).toHaveAttribute('aria-expanded', 'true');
    expect(content).toHaveAttribute('aria-hidden', 'false');
  });
});

Фінальний варіант

import { render } from "@testing-library/react";
import '@testing-library/jest-dom';

describe('FAQItem', () => {
    it("renders critical UI elements", () => {
    const { getByText, getByLabelText } = render(
      <FAQItem question="Question?" answer="Answer" />
    );
    const button = getByLabelText(/expand content/i);
    const question = getByText(/question/i);
    const answer = getByText(/answer/i);
    
    expect(button).toBeInTheDocument();
    expect(question).toBeInTheDocument();
    expect(answer).toBeInTheDocument();
  });
});

Замість снепшотів - тільки критичний UI

Це все дуже добре, але

що робити, якщо проект вже тестує все на Enzyme?

починати з малого 🐜

  1. Визначитись з кандидатурою для тестів
  2. Зробити proof of concept
  3. Показати колегам
  4. Отримати зелене світло

60 UI компонентів,

приблизно половина тестується дуже мінімально (одним снепшотом)

expect(wrapper).toMatchSnapshot();

1. Заміна snapshot-only тестів на більш лаконічні.

import { shallow } from 'enzyme';

const wrapper = shallow(<MyComponent />);

expect(wrapper).toMatchSnapshot();
import { render } from '@testing-library/react';

const component = render(<MyComponent />);

expect(component.asFragment()).toMatchSnapshot();

2. Зміна фокусу з юніт-тестів на інтеграційні тести.

3. Публічний API, гарантований в документації, має бути протестований

const renderCheckbox = (size) => {
  const MOCK_LABEL = "My checkbox";
  const { getByText } = render(<Checkbox label={MOCK_LABEL} size={size} />);
  return getByText(MOCK_LABEL);
};

describe("renders correctly in all available sizes", () => {
  it("m", () => {
    expect(renderCheckbox("m")).toHaveStyle({ "font-size": "16px" });
  });
  it("s", () => {
    expect(renderCheckbox("s")).toHaveStyle({ "font-size": "14px" });
  });
  it("xs", () => {
    expect(renderCheckbox("xs")).toHaveStyle({ "font-size": "12px" });
  });
});

4. Компроміси - це нормально

 

Підхід "ніяких снепшотів, тільки критичний UI" не зустрів підтримки.

Для перестраховки залишили по одному снепшоту на верхніх рівнях компонентів.

¯\_(ツ)_/¯

  1. Відрізняти практичну користь від хайпу
  2. Акцентувати на вирішенні існуючих проблем
  3. Почавши, бути готовим до критики
  4. Аргументувати думку, слухати і йти на компроміси

Підсумок. Як робити великі зміни

semantic-testing-ui

By Yevhen Orlov

semantic-testing-ui

  • 135