Executando verificação de segurança...
4

[Guia] Testando scripts bash com Jest

Olá pessoal, eu sei que é uma coisa muito especifica, mas algum dia pode acabar sendo útil, (no meu caso todos os dias). Antes de mais nada, informo que existe uma ferramenta escrita em bash para testes automatizados que promete exatamente o que estou propondo e que utiliza o próprio bash para testar os scripts (veja aqui). ps: Por incrível que pareça, achei mais fácil usar o Jest (pois já uso ele a muito, muito tempo).

Para este guia, estou utilizando o TypeScript, mas testei com JS puro e funcionou perfeitamente.

Testes automatizados?

Não irei explicar. Caso não conheça sobre este assunto, recomendo fortemente ver este vídeo do @filipedeschamps sobre o assunto. Se já tiver visto, reveja, pois é muito delicinha.

Uma ÚNICA Coisa Me Faz Programar “10x” Mais Rápido (De Verdade)

Configurando o projeto

Vamos começar criando a pasta do projeto:

mkdir sh-with-jest
cd sh-with-jest

yarn init -y ou npm init -y

Apartir daqui utilizarei somente o yarn

yarn add -D typescript ts-node jest ts-jest @types/jest
yarn tsc --init
yarn jest --init

Para as configurações do Jest eu não ativo o coverage, pois não consegui fazer funcionar o rastreamento nos scripts.

Segue o meu jest.config.ts caso queira copiar:

export default {
  roots: ['<rootDir>/src'],
  clearMocks: true,
  collectCoverage: false,
  testEnvironment: 'node',
  moduleFileExtensions: ["js", "ts", "sh"],
  testMatch: ['<rootDir>/src/**/*.test.ts'],
  preset: 'ts-jest'
}

Observe que na linha 6 do meu arquivo, no array existe o item "sh" (moduleFileExtensions: ["js", "ts", "sh"]), é muito importante, pois fará o Jest observar os scripts bash e rodar novamente os testes quando estes forem modificados.

No arquivo package.json eu gosto de adicionar estes comandos (mas é opção pessoal):

  "scripts": {
    "pretest": "jest --clearCache",
    "test": "jest --runInBand",
    "test:w": "yarn test --watchAll"
  },

Show me the code

Projeto configurado, agora vamos ao código.

Vamos criar uma classe chamada Bash, ela será responsável por executar o script e lidar com as saídas de texto.

src/tools/Bash.ts

import { exec } from 'child_process'

export class Bash {
  constructor(private scriptPath: string) {}

  run(parameters: string) {
    return new Promise((resolve, rejects) => {
      exec(`${this.scriptPath} ${parameters}`, (error, stdout, stderr) => {
        if (error) {
          rejects(new Error(stderr))
        }
        resolve(stdout)
      })
    })
  }
}
Clique aqui, para uma explicação linha a linha
import { exec } from 'child_process'
// importa biblioteca necessária

export class Bash {
// cria e exporta classe

  constructor(private scriptPath: string) {}
  // define parametros recebidos ao instanciar classe

  run(parameters: string) {
  // define método que recebe parametros que serão repassados ao script na execução

    return new Promise((resolve, rejects) => {
    // retorna uma promise no método

      exec(`${this.scriptPath} ${parameters}`, (error, stdout, stderr) => {
      // executa script com parametros recebidos, resolve execução com callback

        if (error) {
        // verifica se houve erro

          rejects(new Error(stderr))
          // rejeita promise com stderr do script (isso será explicado posteriormente)
        }
        resolve(stdout)
        // caso dê tudo certo, resolve a promise com o stdout do script
      })
    })
  }
}

Estes links podem ajudar iniciantes a entender alguns conceitos deste arquivo:

Um detalhe importante deste arquivo é o callback do exec:

        if (error) {
          rejects(new Error(stderr))
        }
        resolve(stdout)

Se fosse feito a rejeição do error em vez de do stderr seria mais complexo validar erros no Jest, pois o stderr estaria concatenado com o nome do erro (que pelo que vi sempre é genérico), claro que dessa forma perdemos o código de saída do script, mas há uma saída para essa questão que irei tratar mais para o final.

Testando stdout (comando echo do bash)

Informo que não irei entrar em detalhes sobre o funcionamento do Jest, uma vez que o foco deste post é mostrar como usa-lo para testar scripts bash.

sdtout.sh:

#!/bin/bash

stdout.test.ts:

import { Bash } from "./tools/Bash"

describe('stdout.sh', () => {
  test('deve imprimir "hello Jest" no stdout', async () => {
    const stdout = new Bash('/sh-with-jest/src/stdout.sh')
    const out = await stdout.run('')
    expect(out).toBe('hello Jest')
  })
})

Executando yarn test teremos:

FAIL  src/stdout.test.ts
  stdout.sh
    ✕ deve imprimir "hello Jest" no stdout (13 ms)

  ● stdout.sh › deve imprimir "hello Jest" no stdout

    /bin/sh: 1: /sh-with-jest/src/stdout.sh: Permission denied

Sempre que receber um Permission denied é por que esqueceu de dizer que este arquivo é executável

chmod +x file_name.sh

Agora temos o esperado:

FAIL  src/stdout.test.ts
  stdout.sh
    ✕ deve imprimir "hello Jest" no stdout (23 ms)

  ● stdout.sh › deve imprimir "hello Jest" no stdout

    expect(received).toBe(expected) // Object.is equality

    Expected: "hello Jest"
    Received: ""

Ainda não fizemos nada no nosso shell então agora vamos fazer ele obedecer os testes:

#!/bin/bash

echo "hello Jest"

Opa, primeira pegadinha, o jest retornou:

FAIL  src/stdout.test.ts
  stdout.sh
    ✕ deve imprimir "hello Jest" no stdout (18 ms)

  ● stdout.sh › deve imprimir "hello Jest" no stdout

    expect(received).toBe(expected) // Object.is equality

    - Expected  - 0
    + Received  + 1

      hello Jest
    +

Esse FAIL é por causa do comportamento padrão do echo, ele sempre imprime adicionando um \n (quebra de linha) no fim da string, falamos ao Jest que esperava-mos uma string de 1 (uma) linha contendo hello Jest mas no stdout do script foi retornado uma string de 2 (duas) linhas sendo a primeira com "hello Jest" e a segunda vazia (""), para corrigir este comportamento temos três saídas:

  1. Dizer ao Jest que esperamos "hello Jest\n"
  2. Dizer ao echo que não queremos que ele quebre a linha no final:
echo -n "hello Jest"

Sendo que esta opção não é muito interessante pois você vai querer essa quebra de linha quando adicionar outras saídas com echo.

  1. Utilizar o método de comparação .toContain do Jest.

Eu particularmente neste caso prefiro a opção 3, dependendo do caso, você pode querer a 1. Então o meu teste fica dessa forma:

import { Bash } from "./tools/Bash"

describe('stdout.sh', () => {
  test('deve imprimir "hello Jest" no stdout', async () => {
    const stdout = new Bash('/sh-with-jest/src/stdout.sh')
    const out = await stdout.run('')
    expect(out).toContain('hello Jest')
  })
})

PASS!

PASS  src/stdout.test.ts
  stdout.sh
    ✓ deve imprimir "hello Jest" no stdout (20 ms)

Test Suites: 1 passed, 1 total

Testando stderr

stderr.sh:

#!/bin/bash

stderr.test.ts:

import { Bash } from "./tools/Bash"

describe('stderr.sh', () => {
  test('deve encerrar o script com erro', async () => {
    const stderr = new Bash('/sh-with-jest/src/stderr.sh')
    await expect(stderr.run('')).rejects.toThrowError()
  })
})

mais uma pegadinha:

PASS  src/stderr.test.ts
  stdout.sh
    ✓ deve encerrar o script com erro (25 ms)

Test Suites: 1 passed, 1 total

A explicação do teste ter passado mesmo no script nem ter conteúdo é que novamente não foi feito o chmod no arquivo, o Jest recebe um erro, mas que foi gerado pelo NodeJS, que é o erro de permissão. Executando o script fora de um expect entendemos:

stderr.test.ts:

import { Bash } from "./tools/Bash"

describe('stderr.sh', () => {
  test('deve encerrar o script com erro', async () => {
    const stderr = new Bash('/sh-with-jest/src/stderr.sh')
    await stderr.run('')
  })
})
FAIL  src/stderr.test.ts
  stderr.sh
    ✕ deve encerrar o script com erro (16 ms)

  ● stderr.sh › deve encerrar o script com erro

    /bin/sh: 1: /sh-with-jest/src/stderr.sh: Permission denied

vamos corrigir

chmod +x stderr.sh

stderr.test.ts:

import { Bash } from "./tools/Bash"

describe('stderr.sh', () => {
  test('deve encerrar o script com erro', async () => {
    const stderr = new Bash('/sh-with-jest/src/stderr.sh')
    await expect(stderr.run('')).rejects.toThrowError()
  })
})
FAIL  src/stderr.test.ts
  stderr.sh
    ✕ deve encerrar o script com erro (18 ms)

  ● stderr.sh › deve encerrar o script com erro

    expect(received).rejects.toThrowError()

    Received promise resolved instead of rejected
    Resolved to value: ""

Agora sim! Era esperado que o script terminasse com um erro, mas como ele executou corretamente, o Jest reclamou. Vamos fazer ele obedecer o Jest:

#!/bin/bash

exit 1
PASS  src/stderr.test.ts
  stderr.sh
    ✓ deve encerrar o script com erro (22 ms)

Test Suites: 1 passed, 1 total

Vamos agora validar a descrição dos erros (o próprio stderr na verdade)

stderr.test.ts:

import { Bash } from "./tools/Bash"

describe('stderr.sh', () => {
  test('deve encerrar o script com erro', async () => {
    const stderr = new Bash('/sh-with-jest/src/stderr.sh')
    await expect(stderr.run('')).rejects.toThrowError('Um erro não muito assustador aconteceu')
  })
})
FAIL  src/stderr.test.ts
  stderr.sh
    ✕ deve encerrar o script com erro (26 ms)

  ● stderr.sh › deve encerrar o script com erro

    expect(received).rejects.toThrowError(expected)

    Expected substring: "Um erro não muito assustador aconteceu"
    Received message:   ""

Fazer nosso script obedecer é simples, basta imprimir este erro no stderr:

stderr.sh

#!/bin/bash

echo "Um erro não muito assustador aconteceu" >&2
exit 1
PASS  src/stderr.test.ts
  stderr.sh
    ✓ deve encerrar o script com erro (44 ms)

Test Suites: 1 passed, 1 total

Mas e se eu quiser validar se o meu script encerrou com um código diferente? (ou seja, um erro com significado).

Bom, para isso deverá ser trabalhado na classe Bash, como tudo em programação, pode ser feito de várias formas. A que eu mais gostei foi criar um Error personalizado do JS e lançar ele para que o Jest verifique de forma diferente:

CustomError.ts:

export class CustomError extends Error {
  constructor(feedback?: string) {
    super(feedback)
    Object.setPrototypeOf(this, CustomError.prototype)
    this.name = 'CustomError'
  }
}

E agora verificamos o código retornado do script para lançar este erro (que você pode renomear de acordo com o significado do código):

Bash.ts:

import { exec } from 'child_process'
import { CustomError } from './CustomError'

export class Bash {
  constructor(private scriptPath: string) {}

  run(parameters: string) {
    return new Promise((resolve, rejects) => {
      exec(`${this.scriptPath} ${parameters}`, (error, stdout, stderr) => {
        if (error) {
          if (error.code === 2) {
            rejects(new CustomError(stderr))
          } else {
            rejects(new Error(stderr))
          }
        }
        resolve(stdout)
      })
    })
  }
}

E agora no Jest alteramos nosso teste para esperar um CustomError:

stderr.test.ts:

import { Bash } from "./tools/Bash"
import { CustomError } from "./tools/CustomError"

describe('stderr.sh', () => {
  test('deve encerrar o script com erro', async () => {
    const stderr = new Bash('/sh-with-jest/src/stderr.sh')
    await expect(stderr.run('')).rejects.toThrowError('Um erro não muito assustador aconteceu')
    await expect(stderr.run('')).rejects.toThrowError(CustomError)
  })
})
FAIL  src/stderr.test.ts
  stderr.sh
    ✕ deve encerrar o script com erro (36 ms)

  ● stderr.sh › deve encerrar o script com erro

    expect(received).rejects.toThrowError(expected)

    Expected constructor: CustomError
    Received constructor: Error

    Received message: "Um erro não muito assustador aconteceu
    "

Corrigindo o script:

stderr.sh

#!/bin/bash

echo "Um erro não muito assustador aconteceu" >&2
exit 2
PASS  src/stderr.test.ts
  stderr.sh
    ✓ deve encerrar o script com erro (32 ms)

Test Suites: 1 passed, 1 total

Então, isso é tudo pessoal, foi a primeira vez que escrevi um guia de programação kkk espero que gostem, pelo menos eu gostei de escreve-lo, até a proxima.

Código do projeto

Carregando publicação patrocinada...