2021-09-13

Testando a DOM usando shadow-cljs e Reagent

Há algum tempo tenho tentado desenvolver brundij, uma ferramenta open-source para fazer squad health checks. Eu decidi usar Clojure e Clojurescript para construí-la (já estava estudando Clojure por alguns meses, mas ainda me sentia um completo iniciante, então decidi que construir uma ferramenta usando Clojure poderia me ajudar a entender algumas coisas). Também decidi usar shadow-cljs para o setup do projeto.

Tudo corria bem até o momento em que precisei rodar testes relacionados à DOM: aparentemente poucos dos desenvolvedores usando Clojurescript faziam testes no estilo react-testing-library. O único post que achei falando sobre testes de DOM/componentes/telas era de dois anos atrás. TUdo isso me levou a criar esse post, em que tentarei ajudar iniciantes em Clojurescript a fazer esse tipo de testes em suas aplicações shadow-cljs e reagent.

Primeira tentativa:

Iniciaremos com um projeto básico utilizando shadow-cljs e reagent. Para criar o projeto, podemos usar o leiningen rodando o comando lein new re-frame app. Com o app inicializado, iremos criar nosso primeiro componente -um botão simples:

;;src/app/components/button.cljs
(ns app.components.button)

(defn button [{:keys [on-click text disabled]}]
  [:button
   {:type "button"
    :disabled disabled
    :on-click #(on-click)}
   text])

Esse é um componente bem básico: ele recebe um handler/função on-click, um texto a ser exibido (text) e um booleano disabled. Com o botão criado, vamos tentar renderizá-lo em nossos testes. Para isso, precisamos primeiro criar uma configuração de build de testes em nosso shadow-cljs.edn e depois sim escrever um teste:

  :test {:target :browser-test
         :test-dir "resources/public/js/test"
         :devtools  {:http-port          8021
                     :http-root          "resources/public/js/test"}}}}

;;test/app/button_test.cljs
(ns app.button-test
  (:require [app.components.button :refer [button]]
            [cljs.test :refer-macros [deftest is testing]]
            [reagent.dom :as rdom]))
            
(deftest button-component-test
  (testing "Renders correctly"
    (rdom/render [button {:on-click #(println "hi")
                          :text "button"
                          :disabled false}]
                 (.getElementById js/document "app"))
    (is (= true true))))

Se rodarmos npx shadow-cljs watch test, teremos nosso ambiente de testes rodando. Se você configurou a build de testes do mesmo jeito que eu, você poderá navegar até localhost:8021 e checar que nosso teste está falhando com uma "uncaught exception": target container is not a DOM element

Esse erro é causado pela ausência de um element div com o id app dentro de nosso arquivo HTML de teste (que é utilizado pelo shadow para montar o ambiente de testes). Poderíamos modificar esse arquivo manualmente adicionando essa div lá, mas abordaremos esse problema de uma outra forma.

Renderizando

Como visto acima, precisamos ter uma div com o id app em nossa DOM antes de rodar os testes. UMa maneira de fazer isso sem editar diretamente o arquivo HTML dos testes é usando a função use-fixtures (disponível em clojure.test e cljs.test). Podemos então definir algumas funções que devem ser executadas antes de nossos testes. Vamos criar uma fixture que inserirá a div que precisamos em nossa DOM antes de rodar nossos test cases:

;;test/app/button_test.cljs
(ns app.button-test
  (:require [app.components.button :refer [button]]
            [cljs.test :refer-macros [deftest is testing use-fixtures]]
            [reagent.dom :as rdom]))

(defn create-app-element [f]
  (.appendChild (.-body js/document) ;; gets the Body element
                (doto (.createElement js/document "div") ;; creates a new div
                  (-> (.setAttribute "id" "app")) ;; sets the new div id to be `app`
                  (-> (.setAttribute "style" "display:none;")))) ;; makes that div invisible
  (f))

(use-fixtures :once create-app-element)

(deftest button-component-test
  (testing "Renders correctly"
    (rdom/render [button {:on-click #(println "hi")
                          :text "button"
                          :disabled false}]
                 (.getElementById js/document "app"))
    (is (= true true))))

A função create-app-element nos será muito util e a utilizaremos em cada um de nossos arquivos de teste. Ela basicamente cria uma div, seta o id desta div como app e aplica style= display:none para que esse elemento não fique aparecendo na tela de testes do shadow-cljs (a que vimos acessando localhost:8021). Isso deve ser o suficiente para rodarmos o nosso teste sem recebermos erros quando acessarmos o report de testes.

Fazendo asserções sobre os componentes

Com os componentes renderizando nos testes, podemos agora fazer asserções sobre eles. Suponha que o objetivo seja testar que o botão realmente renderiza: podemos fazer isso de várias maneiras, sendo uma delas checar se a prop text que esse botão recebe acaba sendo renderizada na DOM:

(deftest button-component-test
  (testing "Renders correctly"
    (rdom/render [button {:on-click #(println "hi")
                          :text "button"
                          :disabled false}]
                 (.getElementById js/document "app"))
    (let [app-element (.getElementById js/document "app")
          button (-> (.getElementsByTagName app-element "button")
                     (first))]
      (is (= "button" (.-textContent button))))))

Esse, no entanto, não é um bom teste: ele simplesmente checa se o componente renderiza. Para testarmos os comportamentos de nossos componentes precisamos interagir com eles como um usuário faria, e é aqui que usaremos react-dom/test-utils.

Com o react-dom/test-utils, podemos simular eventos de um usuário e checar se esses eventos trazem mudanças para nossos componentes. No nosso caso, podemos clicar no botão e checar se o on-click dele é ativado:

(ns app.button-test
  (:require [app.components.button :refer [button]]
            [cljs.test :refer-macros [deftest is testing use-fixtures]]
            [reagent.core :as reagent]
            [reagent.dom :as rdom]
            ["react-dom/test-utils" :as dom-test-utils]))

(defn create-app-element [f]
  (.appendChild (.-body js/document)
                (doto (.createElement js/document "div")
                  (-> (.setAttribute "id" "app"))
                  (-> (.setAttribute "style" "display:none;"))))
  (f))

(use-fixtures :once create-app-element)

(deftest button-component-click-test
  (testing "Uses the supplied `on-click` property"
    (let [ra (reagent/atom 1)] ;; we create a new atom with the value of 1
      (rdom/render [button {:on-click #(swap! ra inc) ;; we define that 
                                                      ;;on-click should increment 
                                                      ;;our atom's value
                            :text "button"
                            :disabled false}]
                   (.getElementById js/document "app"))
      (let [app-element (.getElementById js/document "app")
            button (-> (.getElementsByTagName app-element "button")
                       (first))] ;; gets the button element
        (.click dom-test-utils/Simulate button) 
        ;; react-dom/test-utils simulates a user click
        (is (= 2 @ra)))))) ;; ra's value should've been incremented

O teste usa um atom do reagent, mas poderia usar re-frame, por exemplo.

Limpando a DOM entre testes

O setup atual tem um problema: não limpamos a DOM entre os testes. Isso significa que podem ocorrer conflitos entre cada um de nossos deftest. Podemos resolver isso, mas não com o código que temos atualmente: como usamos reagent-dom/render para renderizar nossos componentes na div com id app, não temos esses componentes como "filhos" da div, e sim a div como o componente (o componente toma o lugar da div).

O primeiro passo para podermos limpar a DOM com esse setup será definir uma função append-container. Essa função receberá um elemento destino e um id como argumentos. Com esses dados em mãos, ela criará uma div com o id que recebeu e fará com que essa div seja "filha" do elemento destino. Definiremos então outra função chamada dom-cleanup!, que será uma fixture e que utilizará a função clojure.browser.dom/remove-children, que remove os elementos "filhos" de um elemento escolhido.

(ns app.button-test
  (:require [app.components.button :refer [button]]
            [cljs.test :refer-macros [deftest is testing use-fixtures]]
            [clojure.browser.dom :refer [remove-children]]
            [reagent.core :as reagent]
            [reagent.dom :as rdom]
            ["react-dom/test-utils" :as dom-test-utils]))

(defn create-app-element [f]
  (.appendChild (.-body js/document)
                (doto (.createElement js/document "div")
                  (-> (.setAttribute "id" "app"))
                  (-> (.setAttribute "style" "display:none;"))))
  (f))

(defn dom-cleanup! [f]
  (remove-children "app")
  (f))

(defn append-container [target id]
  (let [container (.getElementById js/document id)]
    (if container
      container
      (.appendChild target (doto (.createElement js/document "div")
                             (-> (.setAttribute "id" id)))))))

(use-fixtures :once create-app-element)
(use-fixtures :each dom-cleanup!)

Com essas funções em mãos, apenas precisamos garantir que nossos testes usem a função append-container para renderizar os componentes:

(deftest button-component-click-test
  (testing "Uses the supplied `on-click` property"
    (let [ra (reagent/atom 1)]
      (rdom/render [button {:on-click #(swap! ra inc)
                            :text "button"
                            :disabled false}]
                   (append-container (.getElementById js/document "app")
                                     "button-click-test")) 
                                     ;; this creates a children div
                                     ;; with the id `button-click-test`
      (let [app-element (.getElementById js/document "button-click-test") 
      ;; queries for the div we've just created
            button (-> (.getElementsByTagName app-element "button")
                       (first))]
        (.click dom-test-utils/Simulate button)
        (is (= 2 @ra))))))

Usando o Karma

Para rodar os testes acima numa CI, será necessário usar o karma. Karma é um test runner para javascript e é recomendado pelo user guide do shadow-cljs. Para usá-lo, vamos adicionar as dependências do karma às nossas dependências de desenvolvimento no nosso package.json, faremos algumas alterações em nosso shadow-cljs.edn e criaremos um arquivo karma.conf.js.

//package.json

{
 "devDependencies": {
  "karma": "^2.0.0",
  "karma-chrome-launcher": "^2.2.0",
  "karma-cljs-test": "^0.1.0",
  "shadow-cljs": "2.15.2"
 }
}
 ;;shadow-cljs.edn
  :ci {:target :karma
       :output-to "target/ci.js"}}}
//karma.conf.js
module.exports = function (config) {
    config.set({
        browsers: ['ChromeHeadless'],
        // The directory where the output file lives
        basePath: 'target',
        // The file itself
        files: ['ci.js'],
        frameworks: ['cljs-test'],
        plugins: ['karma-cljs-test', 'karma-chrome-launcher'],
        colors: true,
        logLevel: config.LOG_INFO,
        client: {
            args: ["shadow.test.karma.init"],
            singleRun: true
        }
    })
};

Com tudo isso feito, poderemos rodar nossos testes em um ambiente de CI rodando o comando npx shadow-cljs compile ci && npm run karma start --single-run

React testing library

Não falarei muito sobre o react-testing-library nesse post. Um excelente post explicando como utilizar o rtl para testar aplicativos Clojurescript já existe. Eu cheguei a tentar seguir o post/tutorial (forçando a versão do RTL para 6.1.2) e consegui fazer meus testes rodarem. Acredito que a maior vantagem do RTL é evitar todo esse processo de interop e manipular a DOM, mas isso não me parece o suficiente para usá-lo no momento.