[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.
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:
- Dizer ao Jest que esperamos "hello Jest\n"
- 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
.
- 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.