Chegamos ao fim do Módulo 5. Quatro artigos densos — testes com Jest, padronização com ESLint e Prettier, fluxo profissional com Git e tipagem estática com TypeScript. Agora vamos unir tudo isso em um único projeto que demonstra como esses pilares de qualidade trabalham juntos.
O projeto é uma API de gerenciamento de produtos construída com TypeScript, Express, MongoDB, completamente testada e com pipeline de qualidade automático.
Revisão rápida — Módulo 5
Jest
// Anatomia de um teste
describe("nomeDaFunção()", () => {
beforeEach(() => jest.clearAllMocks());
it("descrição do comportamento esperado", async () => {
// Arrange
const entrada = criarDadosDeTeste();
// Act
const resultado = await funcaoTestada(entrada);
// Assert
expect(resultado).toMatchObject({ sucesso: true });
expect(mockDependencia).toHaveBeenCalledTimes(1);
});
});
// Tipos: unitários, integração (Supertest), E2E
// Mocks: jest.fn(), jest.mock(), mockResolvedValue
// Coverage: npm run test:coverage
ESLint + Prettier
# ESLint — encontra problemas
npx eslint src/ --fix
# Prettier — formata consistentemente
npx prettier --write src/
# Juntos no package.json
# "quality": "npm run format:check && npm run lint"
# Husky + lint-staged — valida no commit
# commitlint — valida a mensagem do commit
Git semântico
# feat(scope): descrição curta
# fix | docs | refactor | test | chore | perf | ci
git switch -c feature/nome-descritivo
git commit -m "feat(produtos): adiciona endpoint de busca por categoria"
git rebase origin/main # histórico linear antes do PR
git push origin feature/nome-descritivo
# → Pull Request → Revisão → Merge → Delete branch
TypeScript
// Interfaces e types
interface IProduto extends Document { nome: string; preco: number; }
type ProdutoPublico = Omit<IProduto, "estoque">; // IProduto não tem "senha"
type RespostaAPI<T> = { dados: T; sucesso: boolean; };
// Generics
function buscar<T>(id: string): Promise<T | null> { ... }
// Utility Types
Partial<T> Omit<T, K> Pick<T, K> Record<K, V> NonNullable<T>
// Express tipado
async function handler(
req: Request<Params, {}, Body, Query>,
res: Response
): Promise<void> { ... }
O projeto — API de Produtos com qualidade completa
Uma API REST para gerenciamento de catálogo de produtos com:
- TypeScript em todo o projeto
- Testes unitários e de integração com Jest + Supertest
- ESLint + Prettier configurados e funcionando
- Commits semânticos com Husky e commitlint
- Estrutura profissional e escalável
Setup completo
mkdir api-produtos && cd api-produtos
npm init -y
# Produção
npm install express mongoose dotenv bcrypt jsonwebtoken
# Desenvolvimento
npm install -D typescript ts-node-dev
npm install -D @types/node @types/express @types/bcrypt @types/jsonwebtoken
npm install -D jest ts-jest @types/jest supertest @types/supertest
npm install -D eslint @eslint/js prettier eslint-config-prettier
npm install -D husky lint-staged @commitlint/cli @commitlint/config-conventional
Configurações
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
// jest.config.json
{
"preset": "ts-jest",
"testEnvironment": "node",
"testMatch": ["**/tests/**/*.test.ts"],
"collectCoverageFrom": ["src/**/*.ts", "!src/index.ts"],
"coverageThreshold": {
"global": {
"branches": 70,
"functions": 80,
"lines": 80,
"statements": 80
}
},
"setupFiles": ["./tests/setup.ts"]
}
// .prettierrc
{
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"tabWidth": 2,
"printWidth": 100,
"arrowParens": "always"
}
// eslint.config.js
// Projeto em TypeScript exige o parser do TypeScript: sem ele, o ESLint
// não entende anotação de tipo e falha já no primeiro arquivo .ts.
// npm install -D typescript-eslint
const js = require('@eslint/js');
const tseslint = require('typescript-eslint');
const prettier = require('eslint-config-prettier');
module.exports = [
{ ignores: ['node_modules/', 'dist/', 'coverage/'] }, // objeto próprio
js.configs.recommended,
...tseslint.configs.recommended,
{ files: ['**/*.ts'], languageOptions: { parser: tseslint.parser } },
{
languageOptions: {
ecmaVersion: 2024,
sourceType: 'commonjs',
globals: {
require: 'readonly', module: 'readonly',
exports: 'readonly', __dirname: 'readonly',
process: 'readonly', console: 'readonly',
Buffer: 'readonly', setTimeout: 'readonly',
},
},
rules: {
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'eqeqeq': ['error', 'always'],
'no-var': 'error',
'prefer-const': 'error',
'no-console': ['warn', { allow: ['warn', 'error', 'info'] }],
'no-throw-literal': 'error',
},
},
prettier,
];
// package.json — scripts e configurações completas
{
"name": "api-produtos",
"version": "1.0.0",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"quality": "npm run format:check && npm run lint && npm test",
"prepare": "husky"
},
"lint-staged": {
"src/**/*.ts": ["prettier --write", "eslint --fix", "eslint"],
"*.{json,md}": ["prettier --write"]
}
}
// commitlint.config.js
module.exports = {
extends: ['@commitlint/config-conventional'],
};
Estrutura do projeto
src/
├── types/
│ └── index.ts ← todos os tipos e interfaces
├── config/
│ └── database.ts
├── models/
│ └── Produto.ts
├── middlewares/
│ ├── auth.ts
│ ├── validar.ts
│ └── erros.ts
├── controllers/
│ └── produtoController.ts
├── routes/
│ └── produtos.ts
└── index.ts
tests/
├── setup.ts
├── unit/
│ └── validacoes.test.ts
└── integration/
└── produtos.test.ts
Tipos centralizados
// src/types/index.ts
import { Document } from 'mongoose';
// ── Produto ────────────────────────────────────────
export type Categoria =
| 'eletronicos'
| 'roupas'
| 'alimentos'
| 'livros'
| 'outros';
export interface IProduto extends Document {
nome: string;
descricao: string;
preco: number;
estoque: number;
categoria: Categoria;
ativo: boolean;
tags: string[];
criadoEm: Date;
atualizadoEm: Date;
}
export type CriarProdutoBody = Pick<
IProduto,
'nome' | 'descricao' | 'preco' | 'estoque' | 'categoria'
> & { tags?: string[] };
export type AtualizarProdutoBody = Partial<CriarProdutoBody>;
export type ProdutoFiltros = {
categoria?: Categoria;
precoMin?: number;
precoMax?: number;
busca?: string;
ativo?: boolean;
};
// ── Paginação ──────────────────────────────────────
export interface Paginacao {
total: number;
pagina: number;
por_pagina: number;
total_paginas: number;
}
export interface RespostaPaginada<T> {
dados: T[];
paginacao: Paginacao;
}
// ── API ────────────────────────────────────────────
export interface RespostaErro {
erro: string;
detalhes?: string[];
status: number;
}
export interface QueryPaginacao {
pagina?: string;
por_pagina?: string;
ordenar?: string;
}
export type QueryProdutos = ProdutoFiltros & QueryPaginacao;
// ── Express helpers ────────────────────────────────
export interface ParamsId {
id: string;
}
Model tipado
// src/models/Produto.ts
import mongoose, { Model, Schema } from 'mongoose';
import { IProduto, Categoria } from '../types';
const CATEGORIAS: Categoria[] = [
'eletronicos', 'roupas', 'alimentos', 'livros', 'outros',
];
const produtoSchema = new Schema<IProduto>(
{
nome: {
type: String,
required: [true, 'Nome é obrigatório.'],
trim: true,
minlength: [2, 'Nome deve ter pelo menos 2 caracteres.'],
maxlength: [200, 'Nome não pode exceder 200 caracteres.'],
},
descricao: {
type: String,
trim: true,
maxlength: [2000, 'Descrição não pode exceder 2000 caracteres.'],
default: '',
},
preco: {
type: Number,
required: [true, 'Preço é obrigatório.'],
min: [0, 'Preço não pode ser negativo.'],
},
estoque: {
type: Number,
required: [true, 'Estoque é obrigatório.'],
min: [0, 'Estoque não pode ser negativo.'],
default: 0,
},
categoria: {
type: String,
enum: { values: CATEGORIAS, message: 'Categoria inválida.' },
required: [true, 'Categoria é obrigatória.'],
},
ativo: { type: Boolean, default: true },
tags: [{ type: String, trim: true, lowercase: true }],
},
{
timestamps: { createdAt: 'criadoEm', updatedAt: 'atualizadoEm' },
versionKey: false,
}
);
// Índices
produtoSchema.index({ nome: 'text', descricao: 'text' });
produtoSchema.index({ categoria: 1, ativo: 1 });
produtoSchema.index({ preco: 1 });
type ProdutoModel = Model<IProduto>;
export const Produto = mongoose.model<IProduto, ProdutoModel>('Produto', produtoSchema);
Controller tipado
// src/controllers/produtoController.ts
import { Request, Response, NextFunction } from 'express';
import mongoose from 'mongoose';
import { Produto } from '../models/Produto';
import {
CriarProdutoBody,
AtualizarProdutoBody,
QueryProdutos,
ParamsId,
RespostaPaginada,
IProduto,
} from '../types';
// ── Listar ─────────────────────────────────────────
export async function listar(
req: Request<{}, {}, {}, QueryProdutos>,
res: Response,
next: NextFunction
): Promise<void> {
try {
const {
categoria,
precoMin,
precoMax,
busca,
ativo = 'true',
pagina = '1',
por_pagina = '10',
ordenar = '-criadoEm',
} = req.query;
const filtros: Record<string, unknown> = {
ativo: ativo === 'true',
};
if (categoria) filtros.categoria = categoria;
if (busca) filtros.$text = { $search: busca };
if (precoMin !== undefined || precoMax !== undefined) {
filtros.preco = {
...(precoMin !== undefined && { $gte: Number(precoMin) }),
...(precoMax !== undefined && { $lte: Number(precoMax) }),
};
}
// Math.max(1, NaN) é NaN, não 1: sem o || o skip vira NaN e a consulta
// devolve lista vazia diante de ?pagina=abc.
const paginaNum = Math.max(1, parseInt(pagina, 10) || 1);
const porPaginaNum = Math.min(50, Math.max(1, parseInt(por_pagina, 10) || 10));
const skip = (paginaNum - 1) * porPaginaNum;
const [dados, total] = await Promise.all([
Produto.find(filtros)
.sort(ordenar)
.skip(skip)
.limit(porPaginaNum)
.lean(),
Produto.countDocuments(filtros),
]);
const resposta: RespostaPaginada<IProduto> = {
dados: dados as IProduto[],
paginacao: {
total,
pagina: paginaNum,
por_pagina: porPaginaNum,
total_paginas: Math.ceil(total / porPaginaNum),
},
};
res.json(resposta);
} catch (erro) {
next(erro);
}
}
// ── Buscar um ──────────────────────────────────────
export async function buscarUm(
req: Request<ParamsId>,
res: Response,
next: NextFunction
): Promise<void> {
try {
const { id } = req.params;
if (!mongoose.isValidObjectId(id)) {
res.status(400).json({ erro: 'ID inválido.', status: 400 });
return;
}
const produto = await Produto.findById(id);
if (!produto) {
res.status(404).json({ erro: 'Produto não encontrado.', status: 404 });
return;
}
res.json(produto);
} catch (erro) {
next(erro);
}
}
// ── Criar ──────────────────────────────────────────
export async function criar(
req: Request<{}, {}, CriarProdutoBody>,
res: Response,
next: NextFunction
): Promise<void> {
try {
const produto = await Produto.create(req.body);
res.status(201).json(produto);
} catch (erro) {
next(erro);
}
}
// ── Atualizar ──────────────────────────────────────
export async function atualizar(
req: Request<ParamsId, {}, AtualizarProdutoBody>,
res: Response,
next: NextFunction
): Promise<void> {
try {
const { id } = req.params;
if (!mongoose.isValidObjectId(id)) {
res.status(400).json({ erro: 'ID inválido.', status: 400 });
return;
}
const produto = await Produto.findByIdAndUpdate(
id,
{ $set: req.body },
{ new: true, runValidators: true }
);
if (!produto) {
res.status(404).json({ erro: 'Produto não encontrado.', status: 404 });
return;
}
res.json(produto);
} catch (erro) {
next(erro);
}
}
// ── Remover ────────────────────────────────────────
export async function remover(
req: Request<ParamsId>,
res: Response,
next: NextFunction
): Promise<void> {
try {
const { id } = req.params;
if (!mongoose.isValidObjectId(id)) {
res.status(400).json({ erro: 'ID inválido.', status: 400 });
return;
}
const produto = await Produto.findByIdAndDelete(id);
if (!produto) {
res.status(404).json({ erro: 'Produto não encontrado.', status: 404 });
return;
}
res.json({ mensagem: `Produto "${produto.nome}" removido com sucesso.` });
} catch (erro) {
next(erro);
}
}
// ── Estatísticas ───────────────────────────────────
export async function estatisticas(
_req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const stats = await Produto.aggregate([
{ $match: { ativo: true } },
{
$group: {
_id: '$categoria',
total: { $sum: 1 },
precoMedio: { $avg: '$preco' },
precoMin: { $min: '$preco' },
precoMax: { $max: '$preco' },
estoqueTotal: { $sum: '$estoque' },
},
},
{
$project: {
categoria: '$_id',
total: 1,
precoMedio: { $round: ['$precoMedio', 2] },
precoMin: 1,
precoMax: 1,
estoqueTotal: 1,
_id: 0,
},
},
{ $sort: { total: -1 } },
]);
res.json({ categorias: stats });
} catch (erro) {
next(erro);
}
}
Testes
// tests/setup.ts
import mongoose from 'mongoose';
beforeAll(async () => {
const url = process.env.MONGODB_TEST_URL ||
'mongodb://localhost:27017/produtos-test';
await mongoose.connect(url);
});
afterAll(async () => {
await mongoose.connection.dropDatabase();
await mongoose.connection.close();
});
// tests/unit/validacoes.test.ts
import mongoose from 'mongoose';
describe('isValidObjectId()', () => {
it('aceita ObjectId válido', () => {
const id = new mongoose.Types.ObjectId().toString();
expect(mongoose.isValidObjectId(id)).toBe(true);
});
it.each(['abc', '123', '', 'null', undefined])(
'rejeita "%s" como ID inválido',
(id) => {
expect(mongoose.isValidObjectId(id)).toBe(false);
}
);
});
describe('formatarPreco()', () => {
// Importar e testar função utilitária do projeto
function formatarPreco(valor: number): string {
return valor.toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
});
}
it('formata valor positivo', () => {
expect(formatarPreco(1500)).toContain('1.500');
});
it('formata zero', () => {
expect(formatarPreco(0)).toContain('0');
});
it('formata centavos', () => {
expect(formatarPreco(9.99)).toContain('9,99');
});
});
// tests/integration/produtos.test.ts
import request from 'supertest';
import mongoose from 'mongoose';
import app from '../../src/index';
import { Produto } from '../../src/models/Produto';
// Helper para criar produto de teste
function produtoBase(overrides = {}) {
return {
nome: 'Notebook Pro',
descricao: 'Notebook de alta performance',
preco: 3500,
estoque: 10,
categoria: 'eletronicos',
...overrides,
};
}
beforeEach(async () => {
await Produto.deleteMany({});
});
describe('GET /produtos', () => {
it('retorna lista vazia quando não há produtos', async () => {
const res = await request(app).get('/produtos');
expect(res.status).toBe(200);
expect(res.body.dados).toHaveLength(0);
expect(res.body.paginacao.total).toBe(0);
});
it('retorna produtos cadastrados', async () => {
await Produto.create([produtoBase(), produtoBase({ nome: 'Mouse Gamer' })]);
const res = await request(app).get('/produtos');
expect(res.status).toBe(200);
expect(res.body.dados).toHaveLength(2);
expect(res.body.paginacao.total).toBe(2);
});
it('filtra por categoria', async () => {
await Produto.create([
produtoBase({ categoria: 'eletronicos' }),
produtoBase({ nome: 'Camiseta', categoria: 'roupas' }),
]);
const res = await request(app).get('/produtos?categoria=eletronicos');
expect(res.status).toBe(200);
expect(res.body.dados).toHaveLength(1);
expect(res.body.dados[0].categoria).toBe('eletronicos');
});
it('pagina resultados corretamente', async () => {
const produtos = Array.from({ length: 15 }, (_, i) =>
produtoBase({ nome: `Produto ${i + 1}` })
);
await Produto.create(produtos);
const res = await request(app).get('/produtos?pagina=2&por_pagina=5');
expect(res.status).toBe(200);
expect(res.body.dados).toHaveLength(5);
expect(res.body.paginacao.pagina).toBe(2);
expect(res.body.paginacao.total_paginas).toBe(3);
});
it('filtra por faixa de preço', async () => {
await Produto.create([
produtoBase({ preco: 100 }),
produtoBase({ nome: 'Caro', preco: 5000 }),
produtoBase({ nome: 'Médio', preco: 500 }),
]);
const res = await request(app).get('/produtos?precoMin=200&precoMax=1000');
expect(res.status).toBe(200);
expect(res.body.dados).toHaveLength(1);
expect(res.body.dados[0].preco).toBe(500);
});
});
describe('POST /produtos', () => {
it('cria produto com dados válidos', async () => {
const res = await request(app)
.post('/produtos')
.send(produtoBase());
expect(res.status).toBe(201);
expect(res.body).toMatchObject({
nome: 'Notebook Pro',
preco: 3500,
categoria: 'eletronicos',
});
expect(res.body._id).toBeDefined();
});
it('rejeita produto sem nome', async () => {
const res = await request(app)
.post('/produtos')
.send(produtoBase({ nome: undefined }));
expect(res.status).toBe(422);
expect(res.body.detalhes).toEqual(
expect.arrayContaining([expect.stringContaining('Nome')])
);
});
it('rejeita preço negativo', async () => {
const res = await request(app)
.post('/produtos')
.send(produtoBase({ preco: -10 }));
expect(res.status).toBe(422);
});
it('rejeita categoria inválida', async () => {
const res = await request(app)
.post('/produtos')
.send(produtoBase({ categoria: 'inexistente' }));
expect(res.status).toBe(422);
});
});
describe('PUT /produtos/:id', () => {
it('atualiza produto existente', async () => {
const produto = await Produto.create(produtoBase());
const res = await request(app)
.put(`/produtos/${produto._id}`)
.send({ preco: 2999, estoque: 5 });
expect(res.status).toBe(200);
expect(res.body.preco).toBe(2999);
expect(res.body.estoque).toBe(5);
expect(res.body.nome).toBe('Notebook Pro'); // não mudou
});
it('retorna 404 para ID inexistente', async () => {
const idFalso = new mongoose.Types.ObjectId();
const res = await request(app)
.put(`/produtos/${idFalso}`)
.send({ preco: 100 });
expect(res.status).toBe(404);
});
it('retorna 400 para ID malformado', async () => {
const res = await request(app)
.put('/produtos/id-invalido')
.send({ preco: 100 });
expect(res.status).toBe(400);
});
});
describe('DELETE /produtos/:id', () => {
it('remove produto existente', async () => {
const produto = await Produto.create(produtoBase());
const res = await request(app).delete(`/produtos/${produto._id}`);
expect(res.status).toBe(200);
expect(res.body.mensagem).toContain('Notebook Pro');
const removido = await Produto.findById(produto._id);
expect(removido).toBeNull();
});
it('retorna 404 para produto inexistente', async () => {
const idFalso = new mongoose.Types.ObjectId();
const res = await request(app).delete(`/produtos/${idFalso}`);
expect(res.status).toBe(404);
});
});
describe('GET /produtos/estatisticas', () => {
it('retorna estatísticas por categoria', async () => {
await Produto.create([
produtoBase({ preco: 1000 }),
produtoBase({ nome: 'Tablet', preco: 2000 }),
produtoBase({ nome: 'Camiseta', categoria: 'roupas', preco: 100 }),
]);
const res = await request(app).get('/produtos/estatisticas');
expect(res.status).toBe(200);
expect(res.body.categorias).toHaveLength(2);
const eletronicos = res.body.categorias.find(
(c: { categoria: string }) => c.categoria === 'eletronicos'
);
expect(eletronicos.total).toBe(2);
expect(eletronicos.precoMedio).toBe(1500);
});
});
O que este projeto exercitou
| Pilar | Como foi aplicado |
|---|---|
| TypeScript | Tipos centralizados, generics, utility types, Express tipado |
| Jest | Unitários para validações, integração com Supertest |
| ESLint | Regras de qualidade em todo o projeto TS |
| Prettier | Formatação automática consistente |
| Git semântico | Commits por tipo, branches descritivas |
| Husky | pre-commit com lint-staged, commit-msg com commitlint |
| Mongoose tipado | Schema com interface, índices, aggregation |
| Paginação | skip/limit com tipos corretos |
| Filtros | Query params tipados com QueryProdutos |
| Erros | Tratamento tipado em todos os controllers |
Testes, linter, formatador, commit semântico e tipagem parecem cinco assuntos e são um só: passar para a máquina a parte da revisão que não exige julgamento humano. Cada um elimina uma classe inteira de erro antes que ela chegue a outra pessoa — e o efeito colateral é o que mais conta num time, que é a revisão de código deixar de discutir vírgula para discutir decisão.
Fontes e Referências
- Jest + TypeScript — ts-jest: https://kulshekhar.github.io/ts-jest
- Supertest: https://github.com/ladjs/supertest
- TypeScript — Handbook: https://www.typescriptlang.org/docs/handbook/intro.html
- ESLint — TypeScript plugin: https://typescript-eslint.io
- Mongoose + TypeScript: https://mongoosejs.com/docs/typescript.html
- Effective TypeScript — Dan Vanderkam (O'Reilly)
- Testing JavaScript Applications — Lucas da Costa (Manning)
- Kent C. Dodds — Write tests, not too many: https://kentcdodds.com/blog/write-tests
- Jest — primeiros passos: https://jestjs.io/docs/getting-started
Exercícios
Exercício 1
O projeto é inteiro em TypeScript. Esta era a configuração do ESLint antes da correção. O que acontece ao rodar npm run lint?
const js = require('@eslint/js');
const prettier = require('eslint-config-prettier');
module.exports = [
js.configs.recommended,
{
languageOptions: { ecmaVersion: 2024, sourceType: 'commonjs' },
rules: { 'no-unused-vars': 'error', 'eqeqeq': ['error', 'always'] },
},
prettier,
];
Ver resposta
✓ Resposta: Falha no primeiro arquivo, com um erro de parsing do tipo Parsing error: Unexpected token : — o token sendo os dois-pontos de uma anotação como preco: number. O ESLint só entende JavaScript por padrão; para ler TypeScript ele precisa de outro parser, que vem no pacote typescript-eslint. Sem isso, a checagem de qualidade do projeto simplesmente não roda, e o que é pior: como o lint-staged chama o ESLint no hook de pré-commit, o commit passa a falhar por um motivo que não tem relação com o código escrito. Há uma segunda consequência, mais sutil, depois de instalar o parser: a regra no-unused-vars do ESLint base dá falsos positivos em TypeScript — ela acusa parâmetros de tipo, sobrecargas e campos de construtor que o TypeScript entende perfeitamente. O padrão é desligar a regra base e usar a versão do plugin, @typescript-eslint/no-unused-vars, que é o que a configuração recomendada já faz por você. E vale a observação geral para projeto TypeScript: o tsc e o ESLint fazem trabalhos diferentes e complementares — o compilador cuida dos tipos, o linter cuida de padrões e armadilhas —, então rodar um não dispensa o outro.
Exercício 2
Este trecho compila sem nenhum erro. Ainda assim, a asserção da penúltima linha está mentindo. Sobre o quê?
const [dados, total] = await Promise.all([
Produto.find(filtros).sort(ordenar).skip(skip).limit(porPaginaNum).lean(),
Produto.countDocuments(filtros),
]);
const resposta: RespostaPaginada<IProduto> = {
dados: dados as IProduto[],
paginacao: { total, pagina: paginaNum, por_pagina: porPaginaNum, total_paginas: 1 },
};
Ver resposta
✓ Resposta: Sobre o que o .lean() devolve. IProduto estende Document, o que promete métodos de documento do Mongoose — save(), populate(), toObject() —, e o lean() devolve justamente o contrário: objetos JavaScript puros, sem nenhum desses métodos. O as silencia o compilador e passa a informação errada adiante: qualquer código que receba essa RespostaPaginada e chame dados[0].save() compila sem reclamação e quebra em execução com save is not a function. É o mesmo mecanismo do exercício sobre fetch no artigo de TypeScript — asserção não é verificação, é uma promessa que o programador faz e o compilador aceita sem conferir. A correção é modelar o que de fato existe: separar a interface dos dados da interface do documento, com um Produto simples e um IProduto extends Document, Produto, e tipar a resposta com o primeiro. Assim o as deixa de ser necessário — e a regra prática que segue daí é boa: todo as no código é um lugar onde o compilador parou de ajudar, e vale perguntar o que está sendo escondido ali. Vale acrescentar que estender Document na interface do modelo é o padrão antigo do Mongoose, hoje desencorajado justamente por misturar dado com comportamento.
Exercício 3
A listagem funciona bem. Alguém acessa /produtos?pagina=abc e recebe uma lista vazia, com total: 340 na paginação. Por quê?
const paginaNum = Math.max(1, parseInt(pagina, 10));
const porPaginaNum = Math.min(50, Math.max(1, parseInt(por_pagina, 10)));
const skip = (paginaNum - 1) * porPaginaNum;
const [dados, total] = await Promise.all([
Produto.find(filtros).skip(skip).limit(porPaginaNum).lean(),
Produto.countDocuments(filtros),
]);
Ver resposta
✓ Resposta: Porque Math.max(1, NaN) é NaN, e não 1. A intuição diz que o Math.max com um piso de 1 protege contra qualquer entrada ruim, mas NaN não é comparável a nada: qualquer comparação com ele é falsa, e tanto Math.max quanto Math.min propagam o NaN em vez de descartá-lo. Daí skip vira NaN, o driver do MongoDB o interpreta como zero ou ignora, e a combinação com um limit também NaN devolve lista vazia — enquanto o countDocuments, que não usa nenhum dos dois, continua informando o total real. O resultado é uma resposta que se contradiz, do tipo que faz a interface exibir "340 produtos" acima de uma tabela em branco. A guarda correta vem antes do piso: parseInt(pagina, 10) || 1, aproveitando que NaN é falsy, ou Number.isNaN explícito. E repare que o TypeScript não protege aqui — o tipo de parseInt é number, e NaN é um number legítimo, o que faz deste um dos casos em que o sistema de tipos concorda com o compilador e a realidade discorda dos dois.
Exercício 4
O projeto tem Husky, lint-staged e commitlint configurados. Um desenvolvedor consegue enviar código sem formatação e com teste quebrado. Como?
git add .
git commit -m "arrumei" --no-verify
git push
Ver resposta
✓ Resposta: Com --no-verify, que manda o Git pular todos os hooks — pré-commit e commit-msg juntos. E não é preciso nem má intenção: um git commit feito pela interface de alguma ferramenta, um ambiente onde o Husky não foi instalado porque alguém rodou npm install --ignore-scripts, ou um clone novo em que ninguém executou o prepare produzem o mesmo resultado. Hooks rodam na máquina de quem commita, e tudo que roda na máquina de quem commita é opcional por natureza. A conclusão prática é que o hook local é uma conveniência, não um controle: o valor dele é dar retorno em dois segundos, antes de o código sair, poupando uma viagem de ida e volta ao servidor de integração. Quem de fato garante é o pipeline, rodando npm run quality a cada push e a cada Pull Request, somado a uma regra de proteção da branch que impeça o merge enquanto a verificação não passar. Essa camada não depende de configuração de ninguém e não tem --no-verify. A divisão de responsabilidade fica assim: o hook local avisa cedo, o CI decide.
Exercício 5
O coverageThreshold está configurado e a suíte passa com 84% de cobertura. O que essa configuração garante — e o que ela não impede?
"coverageThreshold": {
"global": {
"branches": 70,
"functions": 80,
"lines": 80,
"statements": 80
}
}
Ver resposta
✓ Resposta: Garante uma coisa só, e ela é valiosa: que a cobertura não caia sem alguém perceber. O threshold faz o jest --coverage retornar código de erro quando o número fica abaixo do piso, o que transforma a queda em build vermelho em vez de uma estatística que ninguém olha. É uma trava contra erosão, e é para isso que ela serve. O que ela não faz é bem mais longo. Não garante que os testes verifiquem algo — um teste que chama a função e não tem nenhum expect conta como cobertura integral daquelas linhas. Não garante que os casos de borda foram testados, porque a métrica conta linhas executadas, não cenários considerados. E a palavra global esconde o pior: 84% na média pode significar 100% nos utilitários, que são fáceis de testar, e 20% no controlador de pagamento, que é onde o dinheiro está. Duas medidas melhoram isso muito. A primeira é definir limiares por caminho, exigindo mais dos diretórios críticos que da média do projeto. A segunda é olhar o relatório HTML em vez do número: ele mostra exatamente quais ramos nunca foram percorridos, e é ali que a informação útil está.