przemuh.dev

Uwaga na fixtury w cypress.io

6/26/2020

Dzisiaj opowiem wam krótką historię o błędzie, który kosztował mnie dwa dni poszukiwań. Błędzie, który koniec końców okazał się czymś bardzo trywialnym, a czas który spędziłem na debugowaniu spowodowany był niedokładnym komunikatem o błędzie.

Hej Przemek! Czy mógłbyś mi pomóc?

Kilka dni temu, zauważyłem, że nasze testy VRT (Visual Regression Tests) zaczęły się sypać dla jednego przypadku. Poprosiłem moją koleżankę z zespołu, Monikę, aby rzuciła na to okiem. Monika przyjęła wyzwanie i bezzwłocznie zaczęła szukać rozwiązania. Po całym dniu bezowocnych poszukiwań powiedziała, że nie ma pojęcia dlaczego to nie działa. Lokalnie test przechodził za każdym razem, niestety na naszym GitlabCI było zupełnie odwrotnie. Dziwna sprawa co nie? Jak to jest, że "u mnie działa" a na CI już nie? Zrezygnowana Monika poprosiła mnie o pomoc. Po dwóch dniach kombinowania, commitowania, wypychania, czekania i sprawdzania w końcu się udało.

Fake server

W naszej aplikacji do testów wykorzystujemy różne narzędzia. Do unit testów mamy jest. Do testów E2E mamy py.testa z bindigami do webdrivera. Mamy też testy UI, które sprawdzają naszą aplikację pod kątem integracji pomiędzy komponentami, stronami czy widokami. Niedawno wprowadziliśmy także VRT - wizualne testy regresyjne. Te dwa ostatnie rodzaje testów wykorzystują cypress.io. Jest to świetne narzędzie do pisania wszelakich testów - od unitów do E2E.

Nasza aplikacja od strony backendowej jest bardzo skomplikowana i ciężko ją postawić w całości na lokalnym komputerze, a nawet jeśli jest to możliwe - kosztuje to wiele pracy i zasobów komputera. Dlatego do testów UI i VRT wykorzystujemy jeden z killer-featureów Cypressa, który pozwala mockować zapytania do API. Cypress wpina się pomiędzy aplikację i żądanie do serwera, a my możemy zdecydować o tym jaką odpowiedź dostanie nasza aplikacja.

it("test with network stubbing", () => {
  // Po pierwsze musimy powiedzieć, że stawiamy fake-server
  cy.server()
  // Dalej deklarujemy jakie ścieżki chcemy mockować
  cy.route("/api/endpoint", { value: 1 })
})

Więcej o tym przeczytacie w oficjalnej dokumentacji Cypressa.

Fixtury

Fixtury to kolejna z funkcjonalności cypress.io, z której korzystamy - szczególnie w testach VRT. Fixtura to nic innego jak pewne dane zapisane w pliku, które możemy wykorzystywać wiele razy. Pomaga to w organizacji testów i zarządzaniu odpowiedziami z naszego cy.route. Żeby załadować fixturę korzystamy z komendy cy.fixture. Jako argument przyjmuje ona relatywną ścieżkę do pliku, znajdującego się w katalogu z naszymi fixturami. Domyślnie katalog na fixtury nazywa się po prostu fixtures, a same pliki z fixturami mogą mieć różne rozszerzenia. Nie muszą też być używane w kontekście cy.route. Więcej o fixturach znajdziecie w oficjalnej dokumentacji cypressa.

Załóżmy, że mamy następującą strukturę plików:

- fixtures
    - myFixture.json
    - someSubFolder
          - mySecondFixture.json

Kod, który wykorzystuje fixtury:

it("test with fixtures", () => {
  // Nie musimy deklarować rozszerzenia pliku
  // Cypress spróbuje sam je odgadnąć
  cy.fixture("myFixture").then(data => {
    // Tutaj możemy odczytać dane
  })

  // Możemy zapisać dane w postaci aliasu ...
  cy.fixture("someSubFolder/mySecondFixture").as("myAlias")

  // Żeby móc go wykorzystać przy mockowaniu network requesta
  cy.route("/api/endpoint", "@myAlias")
})

Twórcy cypressa zadbali też o to, żebyśmy nie musieli pisać tak dużo boilerplateu 🔥🔥🔥. Do komendy cy.route jako parametr reprezentujący odpowiedź z serwera możemy podać skrót do fixtury fixture albo fx

cy.route("/api/path", "fixture:myFixture")
cy.route("/api/endpoint", "fx:someSubFolder/mySecondFixture")

W ten sposób zamockowaliśmy sobie odpowiedzi z serwera, a dane trzymane są w re-używalnych plikach z fixturami. Rewelacja!

Gdzie się podział główny bohater opowieści?

No dobra, ale gdzie ten błąd, z którym walczyliśmy dwa dni?

Na potrzeby reprodukcji utworzyłem bardzo prostą aplikację. Na początku wyświetla ona napis Loading…, wykonuje zapytanie do serwera, a następnie podmienia tekst na to, co zwrócił backend.

Pobieranie danych w starym, dobrym stylu XHR 😎

<body>
  <div id="main">Loading...</div>
  <script>
    const mainEl = document.querySelector("#main")

    const req = new XMLHttpRequest()
    req.open("GET", "/api/endpoint", true)
    req.onreadystatechange = function() {
      if (req.readyState == 4) {
        const msg = req.status == 200 ? req.responseText : "Error"
        mainEl.innerHTML = msg
      }
    }
    req.send(null)
  </script>
</body>

Do tego napisałem test:

describe("Simple fixture test", () => {
  it("displays response", function() {
    cy.server()
    cy.route("/api/endpoint", "fixture:examplefixture")

    cy.visit("/")

    cy.get("#main").should("have.text", "Hello")
  })
})

Oraz utworzyłem plik z fixturą w pliku fixtures/exampleFixture.json z taką zawartością:

Hello

Czy widzisz już gdzie leży błąd?

W odnalezieniu przyczyny problemu pomógł mi zrzut ekranu, który cypress wykonuje w momencie kiedy test nie przejdzie. Kolejny killer feature 🔥.

Zrzut ekranu dla testu
Zrzut ekranu dla testu

Czy teraz domyślasz się gdzie był błąd?

Moją uwagę przykuł komunikat o tym, że route został co prawda zestubowany, ale odpowiedział kodem 400, a nie 200, co spowodowało błąd w kolejnej komendzie oczekującej na element z tekstem "FolderA". Przypominam, że "lokalnie" ten test nie miał z tym problemu 😉.

Literówka i systemy plików

Nasz błąd, który próbowaliśmy rozwiązać z Moniką, polegał na banalnej literówce. Nazwa pliku z fixturą zapisana była camelCase, natomiast w kodzie testu mieliśmy wszystko małymi literami.

exampleFixture.json vs cy.route("/api", "fixture:examplefixture")

No ok, ale dlaczego to działało lokalnie, a nie działało na CI?

99% naszego zespołu frontendowego pracuje na MacBookach podczas gdy GitlabCi odpala testy w kontenerze dockerowym, który opiera się na Linuxie. Co to ma wspólnego z fixturami i naszą literówką? Otóż system plików używany domyślnie w Linuxie jest caseSensitive (zwraca uwagę na wielkość liter w nazwach plików). MacOS oraz Windows domyślnie opierają się o system plików, który caseSensitive nie jest. Co to oznacza w praktyce?

Na Linuxie możemy utworzyć pliki o tej samej nazwie, ale pisane nieco inaczej np.

  • myAwesomeFile.js
  • myawesomefile.js

Linux potraktuje je jako dwa osobne pliki. Natomiast Windows i MacOS nie pozwolą na stworzenie drugiego pliku o tej samej nazwie (pisanej np. camelCase). Przy próbie odczytu pliku np. w node.js na MacOS nie ma znaczenia czy wpiszemy myFixture czy mYFiXtURe - plik zostanie załadowany. Na Linuxie natomiast dostaniemy błąd odczytu - plik nie został odnaleziony.

Sprawdzam

I faktycznie tak jest. Gdy zmodyfikujemy kod naszego testu w ten sposób:

cy.route("/api/endpoint", "fixture:ExAmPlEFiXTuRe")

Test na Macu przechodzi zawsze. Na Linuxie natomiast, cypress logger pokaże nam stuba z kodem 400:

Zrzut ekranu z kodem 400
Zrzut ekranu z kodem 400

a w konsoli dostaniemy błąd:

CypressError: The following error originated from your application code, not from Cypress.

When Cypress detects uncaught errors originating from your application it will automatically fail the current test.

This behavior is configurable, and you can choose to turn this off by listening to the `uncaught:exception` event.

https://on.cypress.io/uncaught-exception-from-application

Ale jak to “błąd nie jest w cypressie tylko w mojej apce”? Przecież apka działa, test na Macu też przechodzi - więc o co kaman?

Spróbujmy skorzystać z naszej fixtury za pomocą cy.fixture zamiast skrótu:

// Celowy błąd w nazwie fixtury
cy.fixture("examplEFixture").as("response")
cy.route("/api/endpoint", "fixture:examplefixture")

// Bonusem takiego podejścia jest możliwość użycia aliasu do odczytu danych
// zamiast hardcodować "Hello" w teście
cy.get("@response").then(data => {
  cy.get("#main").should("have.text", data)
})

Jaki teraz dostaniemy błąd?

Error: A fixture file could not be found at any of the following paths:

> cypress/fixtures/examplEFixture
> cypress/fixtures/examplEFixture{{extension}}

Cypress looked for these file extensions at the provided path:
.json, .js, .coffee, .html, .txt, .csv, .png, .jpg, .jpeg, .gif, .tif, .tiff, .zip

Provide a path to an existing fixture file.

👏 no i taki komunikat błędu jest o wiele lepszy. Od razu wiemy gdzie należy szukać 😎

Podsumowanie

Z tej historii można wyciągnąć dwa wnioski:

  • mała literówka może przysporzyć Ci kilka dni debugowania
  • jesteś tak dobry jak komunikaty o błędzie z twojego test runnera 😉

Myślę, że cypress mógłby zwracać “poprawny” komunikat o błędnej fixturze zamiast CypressError- dlatego zgłosiłem issue, którego status możecie śledzić tutaj.

Dzięki, że wytrwaliście ze mną w tej historii do końca. Lecę spróbować naprawić issue, które sam zgłosiłem 😉. Może uda mi się dołożyć kolejną cegiełkę do OpenSource i sprawdzić by Cypress, który jest niesamowitym narzędziem, stał się jeszcze lepszy 😁