DEV Community

Cover image for Testy statyczne vs jednostkowe vs E2E w aplikacjach frontendowych
Bartosz Zagrodzki
Bartosz Zagrodzki

Posted on

Testy statyczne vs jednostkowe vs E2E w aplikacjach frontendowych

Przetłumaczona wersja artykułu Kenta C. Doddsa

W moim wywiadzie "Praktyki testowania z J. B. Rainsbergerem dostępnym na TestingJavaScript.com usłyszałem coś takiego:

"Możesz rzucać farbą o ścianę pokrywając dużą jej część, ale dopóki nie użyjesz pędzla nie pokryjesz narożników. 🖌️"

Uwielbiam tę metaforę w odniesieniu do testowania, ponieważ zasadniczo mówi ona, że wybór odpowiedniej strategii testowania jest tym samym rodzajem wyboru, jakiego dokonujesz wybierając pędzel do malowania ściany. Czy na całej ścianie użyłbyś pędzelka o cienkim włosiu? Oczywiście, że nie. Zajęłoby to mnóstwo czasu, a efekt końcowy pewnie i tak nie byłby zadowalający. Czy użyłbyś wałka, aby pomalować wszystko, łącznie z zamontowanymi meblami, które twoja praprababka przywiozła zza oceanu dwieście lat temu? Nie ma mowy. Istnieją różne pędzle do różnych sytuacji, dokładnie tak jak w przypadku testów.

Dlatego stworzyłem Trofeum Testów (The Testing Trophy). W międzyczasie Maggie Appleton (autorka mistrzowskiego designu egghead.io) stworzyła tą grafikę dla TestingJavaScript.com:
Trofeum Testów

Wyróżniamy tutaj 4 typy testów. Powyższa grafika wszystko pokazuje, ale dla tych używających czytników ekranu (lub tych, którym obraz się nie załadował) opiszę wszystko poniżej:

  • End to End: robot, który używa strony jak użytkownik, klika i sprawdza, czy wszystko działa. Czasami nazywane „testami funkcjonalnymi” lub e2e.

  • Integracyjne: weryfikują, czy kilka pojedynczych elementów wspólnie dobrze działa.

  • Jednostkowe: sprawdzają pojedyncze elementy naszej aplikacji.

  • Statyczne: wyłapują literówki i inne błędy w trakcie pisania kodu.

Rozmiar poszczególnych poziomów nie jest przypadkowy, zależy od stopnia skupienia się na nich podczas testowania aplikacji (ogólnie). Chcę bardziej wyjaśnić te różne formy testowania, jak używa się ich w praktyce i co możemy zrobić aby zwiększyć pewność jaką mogę one nam dać.

Typy testów

Spójrzmy na kilka przykładów tego rodzaju testów, idąc od góry do dołu:

End to End

Zazwyczaj uruchamiają one całą aplikację (zarówno frontend, jak i backend), a Twój test będzie działał z aplikacją tak, jak zrobiłby to typowy użytkownik. Przykład technologii: Cypress

import {generate} from 'todo-test-utils'

describe('todo app', () => {
  it('should work for a typical user', () => {
    const user = generate.user()
    const todo = generate.todo()
    // będziemy tutaj przechodzić przez proces rejestracji.
    // Zazwyczaj mamy tylko jeden taki test.
    // pozostałe testy będą korzystać z tego samego endpoint-u
    // co aplikacja, więc pomożemy pominąć nawigację w tym środowisku
    cy.visitApp()

    cy.findByText(/register/i).click()

    cy.findByLabelText(/username/i).type(user.username)

    cy.findByLabelText(/password/i).type(user.password)

    cy.findByText(/login/i).click()

    cy.findByLabelText(/add todo/i)
      .type(todo.description)
      .type('{enter}')

    cy.findByTestId('todo-0').should('have.value', todo.description)

    cy.findByLabelText('complete').click()

    cy.findByTestId('todo-0').should('have.class', 'complete')
    // itp...
    // Moje testy E2E zazwyczaj symulują prawdziwe zachowanie użytkownika.
    // Czasami mogą być naprawdę długie.
  })
})
Enter fullscreen mode Exit fullscreen mode

Integracyjne

Test poniżej renderuje całą aplikację. NIE jest to wymagane w testach integracyjnych, a większość moich testów tego nie robi. Będą jednak renderowane ze wszystkimi dostawcami używanymi w aplikacji (to właśnie robi metoda render z wyimaginowanego modułu „test/app-test-utils”). Ideą testów integracyjnych jest, by jak najmniej używać mocków. Mockuję tylko:

  1. Żądania sieciowe (używając MSW)
  2. Komponenty odpowiedzialne za animacje (kto chciałby na to czekać podczas testu?)
import * as React from 'react'
import {render, screen, waitForElementToBeRemoved} from 'test/app-test-utils'
import userEvent from '@testing-library/user-event'
import {build, fake} from '@jackfranklin/test-data-bot'
import {rest} from 'msw'
import {setupServer} from 'msw/node'
import {handlers} from 'test/server-handlers'
import App from '../app'

const buildLoginForm = build({
  fields: {
    username: fake(f => f.internet.userName()),
    password: fake(f => f.internet.password()),
  },
})

// testy integracyjne zazwyczaj mockują tylko żądania HTTP za pomocą MSW
const server = setupServer(...handlers)

beforeAll(() => server.listen())
afterAll(() => server.close())
afterEach(() => server.resetHandlers())

test(`logging in displays the user's username`, async () => {
  // Customowy render zwraca obietnicę, która zostaje rozwiązana po 
  //   zakończeniu ładowania aplikacji (jeśli renderujesz po stronie serwera, możesz tego nie potrzebować).
  // Customowy render umożliwia Ci również ustawienie początkowej ścieżki
  await render(<App />, {route: '/login'})
  const {username, password} = buildLoginForm()

  userEvent.type(screen.getByLabelText(/username/i), username)
  userEvent.type(screen.getByLabelText(/password/i), password)
  userEvent.click(screen.getByRole('button', {name: /submit/i}))

  await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i))

  // potwierdź wszystko, czego potrzebujesz, aby sprawdzić, czy użytkownik jest zalogowany
  expect(screen.getByText(username)).toBeInTheDocument()
})
Enter fullscreen mode Exit fullscreen mode

W tym celu zazwyczaj mam również kilka rzeczy skonfigurowanych globalnie, takich jak automatyczne resetowanie wszystkich mocków między testami.

Dowiedz się jak skonfigurować plik konfiguracyjny test-utils, taki jak ten powyżej w dokumentacji biblioteki React Testing Library

Jednostkowe

import '@testing-library/jest-dom/extend-expect'
import * as React from 'react'
// jeśli masz moduł test-utils, jak w przykładzie powyżej
// użyj zamiast niego @testing-library/react
import {render, screen} from '@testing-library/react'
import ItemList from '../item-list'

// Niektórzy nie nazywają tego testem jednostkowym, ponieważ renderujemy do DOM za pomocą Reacta.
// Powiedzieliby, żebyś zamiast tego używał renderowania płytkiego.
// Kiedy ci to powiedzą, wyślij ich na https://kcd.im/shallow
test('renders "no items" when the item list is empty', () => {
  render(<ItemList items={[]} />)
  expect(screen.getByText(/no items/i)).toBeInTheDocument()
})

test('renders the items in a list', () => {
  render(<ItemList items={['apple', 'orange', 'pear']} />)
  // note: w przypadku czegoś tak prostego mógłbym rozważyć użycie snapshot-a, ale tylko wtedy, gdy:
  // 1. snapshot jest mały
  // 2. używamy toMatchInlineSnapshot()
  // Dowiedz się więcej: https://kcd.im/snapshots
  expect(screen.getByText(/apple/i)).toBeInTheDocument()
  expect(screen.getByText(/orange/i)).toBeInTheDocument()
  expect(screen.getByText(/pear/i)).toBeInTheDocument()
  expect(screen.queryByText(/no items/i)).not.toBeInTheDocument()
})
Enter fullscreen mode Exit fullscreen mode

Wszyscy nazywają to testem jednostkowym i mają rację:

// "czyste" funkcje są NAJLEPSZE do testów jednostkowych, KOCHAM używanie do nich biblioteki jest-in-case!
import cases from 'jest-in-case'
import fizzbuzz from '../fizzbuzz'

cases(
  'fizzbuzz',
  ({input, output}) => expect(fizzbuzz(input)).toBe(output),
  [
    [1, '1'],
    [2, '2'],
    [3, 'Fizz'],
    [5, 'Buzz'],
    [9, 'Fizz'],
    [15, 'FizzBuzz'],
    [16, '16'],
  ].map(([input, output]) => ({title: `${input} => ${output}`, input, output})),
)
Enter fullscreen mode Exit fullscreen mode

Statyczne

// dostrzegasz błąd?
// założę się, że ESLint
// wyłapie to szybciej niż Ty w code review 😉
for (var i = 0; i < 10; i--) {
  console.log(i)
}

const two = '2'
// ok, ten przykład jest trochę wymyślony,
// ale TypeScript zwróci Ci uwagę na błąd:
const result = add(1, two)
Enter fullscreen mode Exit fullscreen mode

Dlaczego testujemy ponownie?

Myślę, że ważne jest, aby pamiętać, dlaczego w ogóle piszemy testy. Dlaczego piszesz testy? Czy to dlatego, że ci kazałem? Czy to dlatego, że Twój PR bez testów zostanie odrzucony? Czy to dlatego, że testowanie usprawnia workflow?

Największym i najważniejszym powodem, dla którego piszę testy jest PEWNOŚĆ. Chcę być pewny, że kod, który piszę na przyszłość, nie zepsuje aplikacji, którą mam obecnie uruchomioną w środowisku produkcyjnym. Więc cokolwiek robię, chcę mieć pewność, że rodzaje testów, które piszę, dadzą mi największą możliwą pewność siebie i muszę być świadomy kompromisów, których dokonuję podczas testowania.

Porozmawiajmy o kompromisach

Jest kilka ważnych elementów trofeum testów, które chcę przedstawić na tym zdjęciu (wyrwanym z moich slajdów):
Trofeum testów ze strzałkami wskazującymi na kompromisy

Strzałki na obrazku oznaczają trzy kompromisy, jakie podejmujesz podczas pisania testów automatycznych:

Koszt: ¢heap ➡ 💰🤑💰

W miarę przesuwania się w górę trofeum testy stają się coraz bardziej kosztowne. Odbija się to na rzeczywistych kosztach testów w środowisku CI, ale także na czasie potrzebnym na napisanie i utrzymanie każdego indywidualnego testu.

Im wyżej jesteśmy (w trofeum testów), tym więcej jest punktów niepowodzenia, a zatem tym bardziej prawdopodobne jest, że test się wysypie, co prowadzi do większej ilości czasu potrzebnego na analizę i naprawę. Pamiętaj o tym, to ważne #zapowiedz...

Prędkość: 🏎💨 ➡ 🐢\

Im wyżej w trofeum tym testy zwykle przebiegają wolniej. Wynika to z faktu, że Twój test wykonuje coraz więcej kodu. Testy jednostkowe zazwyczaj testują coś małego, co nie ma zależności lub mockują te zależności (skutecznie zamieniając tysiące wierszy kodu na kilka linii). Pamiętaj o tym, to ważne #zapowiedz...

Pewność: Proste sprawy 👌 ➡ Duże problemy 😖

Kompromisy w zakresie kosztów i szybkości są zwykle przywoływane, gdy ludzie mówią o piramidzie testów 🔺. Gdyby to były jedyne kompromisy, skupiłbym 100% swoich wysiłków na testach jednostkowych i całkowicie zignorował wszelkie inne formy testowania w odniesieniu do piramidy testowej. Oczywiście nie powinniśmy tego robić, a to z powodu jednej bardzo ważnej zasady, o której prawdopodobnie słyszałeś już wcześniej:

"Im bardziej Twoje testy przypominają sposób korzystania z oprogramowania, tym większą pewność mogą Ci dać."

Co to znaczy? Oznacza to, że nie ma lepszego sposobu, aby upewnić się, że Twoja ciocia Marie będzie mogła rozliczać podatki za pomocą Twojego oprogramowania podatkowego, niż faktyczne wykonanie tego rozliczenia. Ale nie chcemy czekać, aż ciocia Marie znajdzie dla nas bugi, prawda? Zajęłoby to zbyt długo i prawdopodobnie i tak przegapiłaby ona niektóre funkcje, które powinniśmy przetestować. W połączeniu z faktem, że regularnie publikujemy aktualizacje naszego oprogramowania, nie ma mowy, aby jakakolwiek liczba ludzi była w stanie za tym nadążyć.

Więc co robimy? Idziemy na kompromis. Jak? Piszemy oprogramowanie testujące nasze oprogramowanie. A kompromis, na który zawsze musimy się zgodzić, polega na tym, że nasze testy nie przypominają sposobu, w jaki nasze oprogramowanie jest używane tak niezawodnie, jak wtedy, gdy testowała je ciocia Marie. Ale robimy to, ponieważ dzięki takiemu podejściu rozwiązujemy realne problemy. Postępujemy tak na każdym etapie trofeum testów.

Wspinając się w górę trofeum testowego, zwiększasz to, co nazywam "współczynnikiem ufności". Jest to względna pewność, że każdy test może doprowadzić Cię do tego poziomu. Możesz sobie wyobrazić, że nad trofeum znajduje się testowanie ręczne. Takie testowanie dałoby Ci największą pewność, ale byłoby bardzo drogie i wolne.

Wcześniej wspominałem, byś zapamiętał dwie rzeczy:

  • Im wyżej zajdziesz w hierarchii testów, tym więcej jest punktów niepowodzenia, a zatem tym większe prawdopodobieństwo, że test się wysypie

  • Testy jednostkowe zazwyczaj testują coś małego, co nie ma
    zależności lub będzie mockować te zależności (skutecznie zamieniając tysiące wierszy kodu na kilka).

Mówią one, że im niżej jesteś w trofeum, tym mniej kodu jest testowane. Będąc na niższym poziomie potrzebujesz większej liczby testów, by obsłużyć tyle linii kodu, ile mógłby wykonać jeden test na wyższym poziomie. W rzeczywistości, gdy schodzisz w trofeum testów, są pewne rzeczy, których nie można przetestować.

W szczególności narzędzia do analizy statycznej nie są w stanie zapewnić zaufania do logiki biznesowej. Testy jednostkowe nie są w stanie zapewnić, że gdy wywołujesz zależność, robisz to właściwie (chociaż możesz tworzyć asercje dotyczące tego, jak jest wywoływana, nie możesz upewnić się, że jest wywoływana poprawnie za pomocą testu jednostkowego). Testy integracyjne UI nie dają pewności, że przekazujesz właściwe wartości na backend oraz czy poprawnie obsługujesz błędy. Testy End to End są całkiem sprawne, ale zazwyczaj będziesz je uruchamiał w nieprodukcyjnym środowisku (podobnym do produkcyjnego, ale nie produkcyjnym), aby wymienić pewność na praktyczność.

Spójrzmy na to z innej strony. Na szczycie trofeum testów, jeśli spróbujesz użyć testu E2E, aby sprawdzić, czy wpisując coś w określone pole i klikając przycisk związany z integracją między formularzem a generatorem adresów URL, wszystko działa prawidłowo, potrzebujesz uruchomić całą aplikację (w tym backend). To byłoby bardziej odpowiednie dla testu integracyjnego. Jeśli spróbujesz użyć takiego testu, by sprawdzić przypadek kalkulatora do wprowadzania kodu kuponu, prawdopodobnie dokonasz wielu operacji
w funkcji konfiguracyjnej, aby upewnić się, że możesz renderować komponenty korzystające z tego kalkulatora i obsłużyć ten przypadek w teście jednostkowym. Jeśli spróbujesz użyć testu jednostkowego, aby zweryfikować, co się stanie, gdy wywołasz funkcję add ze stringiem zamiast liczby, lepszym pomysłem będzie użycie statycznego narzędzia do sprawdzania typu, takiego jak TypeScript.

Wnioski

Każdy poziom wiążę się ze swoimi własnymi kompromisami. Test E2E ma więcej miejsc, w których może się nie powieść, co często utrudnia wyśledzenie kodu, który spowodował uszkodzenie, ale oznacza to również, że test daje większą pewność. Jest to szczególnie przydatne, jeśli nie masz wystarczająco dużo czasu na pisanie testów. Wolałbym zachować pewność i próbować znaleźć błąd, niż nie wyłapywać problemu za pomocą testu.

W końcu nie zależy mi na wyróżnieniach. Jeśli chcesz nazwać moje testy jednostkowe integracyjnymi lub nawet E2E (jak niektórzy ludzie to robią 🤷‍♂️), to niech tak będzie. Interesuje mnie to, czy mam pewność, że kiedy wysyłam zmiany, mój kod spełnia wymagania biznesowe i zastosuję tyle kombinacji różnych strategii testowania, ile będzie potrzebnych, aby osiągnąć cel.

Powodzenia!

Top comments (0)