Revisão + Projeto: API tipada e testada

[119] Revisão + Projeto: API tipada e testada

Os quatro artigos do módulo se encontram num projeto só: uma API de produtos escrita em TypeScript, com tipos centralizados, model e controller tipados, testes de unidade e de integração, ESLint com o parser certo, Prettier, e o commit barrado por Husky quando alguma dessas peças não passa.
Javascript

20 min de leitura

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

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á.

Comentários

Mais em Javascript

O que é o DOM e como o JavaScript interage com o HTML
O que é o DOM e como o JavaScript interage com o HTML

O navegador não guarda o seu HTML como texto: ele o transforma numa árvore de…

Introdução ao Node.js: JavaScript fora do navegador
Introdução ao Node.js: JavaScript fora do navegador

Em 2009 o JavaScript saiu da aba do navegador. O Node roda o mesmo motor V8…

Funções: declaração, expressão e arrow functions
Funções: declaração, expressão e arrow functions

Uma função sem return devolve undefined — e uma arrow com chaves, mas sem…