Como escrever serviços HTTP em Go depois de 13 anos
(grafana.com)- Serviços HTTP em Go que precisam ser mantidos por muito tempo ficam mais fáceis de manter e validar quando são compostos por passagem explícita de dependências, rotas reunidas em um só lugar e uma função
runtestável - Em vez de métodos de uma struct de servidor, crie handlers como funções que retornam
http.Handlere recebem os valores necessários via closures; componha middlewares comuns nas etapas de criação do servidor e registro de rotas - Manter
func main()enxuta e injetar emrun()context.Context, argumentos, acesso ao ambiente e entrada/saída padrão simplifica o tratamento de encerramento e o controle em testes - Codificação de requisições/respostas, validação, adaptadores de middleware e inicialização preguiçosa com
sync.Oncereduzem código repetitivo ao mesmo tempo que preservam o fluxo padrão do Go emnet/http - Os testes tendem a preferir uma abordagem end-to-end, mais próxima de chamadas reais à API, em vez de testar handlers individuais; cada teste sobe seu próprio servidor e verifica o estado de prontidão com
/healthzou/readyz
Criação do servidor e ponto de entrada do serviço
- O construtor
NewServerdeve ser a função que cria ohttp.Handlercentral do serviço- Normalmente há um por serviço, e as rotas internas encaminham as requisições para cada handler
- Todas as dependências, como logger, configuração, storage e clientes externos, são recebidas como argumentos
- Sempre que possível, retorna
http.Handler; em casos complexos, pode usar um tipo dedicado - Depois de configurar seu próprio muxer, passa-o para a função de registro de rotas em
routes.go
- O processamento HTTP comum necessário a todos os endpoints é agrupado em
NewServer- CORS
- Middleware de autenticação
- Logging
- Middleware de ID de rastreamento
- Mesmo que a lista de dependências fique longa, a preferência é explicitá-las como argumentos de função
- Se um campo de uma struct for omitido, o compilador talvez não consiga impedir isso; já uma função não pode ser chamada sem que os valores necessários sejam passados
- Listas longas de argumentos ficam mais legíveis quando formatadas verticalmente
- Dependências não usadas em um teste específico são passadas como
nil, sinalizando que não serão utilizadas
Reunir a superfície da API em routes.go
routes.godeve ser o arquivo em que todas as rotas do serviço podem ser vistas em um só lugar- Cada projeto passa a ter um local único para inspecionar a superfície da API
- Por causa da grande lista de dependências de
NewServer,addRoutespode acabar tendo uma lista de argumentos parecida - A checagem de tipos do Go captura argumentos ausentes ou em ordem incorreta
addRoutesdeve ser mantida simples e plana sempre que possível- Operações que podem falhar são tratadas antes, na função
run - Na etapa de registro de handlers, o foco fica em roteamento com
mux.Handle,mux.HandleFunc,http.NotFoundHandlere similares - Se o design exigir que o próprio handler retorne erro,
addRoutestambém pode retornar erro
- Operações que podem falhar são tratadas antes, na função
main deve apenas chamar run
func main()deve ser uma função enxuta que chamarun()e, se houver erro, escreve emstderre encerra com falharunrecebe como argumentos elementos básicos do sistema operacional, comocontext.Context, argumentos, entrada/saída e função de acesso ao ambiente- Como
runretorna erro, é possível tratar erros como em código Go comum
- Exemplos de valores que podem ser passados para
run:os.Args: usado para argumentos de execução do programa e parsing de flagsos.Stdin: leitura de entradaos.Stdout: escrita de saídaos.Stderr: escrita de logs de erroos.Getenv: leitura de variáveis de ambienteos.Getwd: obtenção do diretório de trabalho atual
signal.NotifyContexté configurado dentro derun- Quando chega um sinal de encerramento, como
Ctrl+C, o contexto é cancelado - Se
runretornarnil, o encerramento é normal - Se retornar erro,
mainimprime o erro e encerra com código diferente de 0
- Quando chega um sinal de encerramento, como
- Evitar estado global permite usar
t.Parallel()em mais testes- Mesmo que
runseja chamada várias vezes, cada execução não interfere nas outras - Flags são tratadas com
flags.NewFlagSetdentro derun, em vez doflagglobal - Variáveis de ambiente são controladas injetando
getenv func(string) string, em vez de alterar o ambiente real - Diferentemente de
t.SetEnv, essa abordagem permite continuar usando testes paralelos
- Mesmo que
Encerramento e tratamento de prontidão
- O contexto deve ser propagado por todas as camadas do serviço
- Quando chega um sinal de encerramento, o contexto é cancelado
- Tarefas longas ou repetitivas verificam
ctx.Err()ouctx.Done()e interrompem a execução - Mesmo ao iniciar outras goroutines, o contexto é usado para decidir quando parar
- Ao encerrar, o servidor HTTP chama
Shutdownpara parar de forma graceful- No exemplo, uma goroutine separada aguarda
ctx.Done() - O contexto de encerramento tem timeout de
10 * time.Second - Se houver erro durante o encerramento, ele é registrado em
stderr
- No exemplo, uma goroutine separada aguarda
- Para verificar em testes se o servidor está realmente pronto, exponha endpoints
/healthzou/readyz- Também seria possível criar um sinal de prontidão por um canal separado, mas a preferência é confirmar por meio de uma requisição HTTP real
- O loop de verificação de prontidão faz requisições até receber
200 OK - Se o contexto for cancelado ou o timeout for atingido, retorna erro
- O loop de exemplo espera
250msentre as requisições
Forma de compor handlers
- Funções de handler devem retornar
http.Handlerouhttp.HandlerFunc, em vez de implementá-los diretamente- Ex.:
func handleSomething(logger *Logger) http.Handler - É possível criar um ambiente de closure específico para cada handler
- Valores inicializados podem ser usados durante o processamento das requisições
- Ex.:
- Dados compartilhados são seguros apenas quando usados como somente leitura
- Se o handler modificar valores, é necessário algum mecanismo de proteção, como mutex
- Armazenar estado do programa em closures geralmente não é recomendado
- Em ambientes de cloud, é difícil presumir que uma instância permanecerá viva por muito tempo
- O servidor pode ser derrubado para economizar recursos ou pode cair por outros motivos
- Várias instâncias podem estar em execução ao mesmo tempo, e as requisições podem ser distribuídas de formas difíceis de prever
- Em projetos reais, é melhor manter estado persistente em um banco de dados ou em uma API de armazenamento separada
Codificação/decodificação de requisições/respostas e validação
- Todo serviço precisa decodificar corpos de requisições e codificar corpos de respostas, então é útil ter helpers
encode/decode- O exemplo define o
Content-Typecomo JSON, escreve o código de status e então chamajson.NewEncoder(w).Encode(v) - A decodificação envolve
json.NewDecoder(r.Body).Decode(&v)e adiciona contexto ao erro - Com generics, é possível inferir o tipo em chamadas como
encode(w, r, http.StatusOK, obj) - Como
decodetem tipo de retorno, é preciso explicitar o tipo esperado, como emdecode[CreateSomethingRequest](https://grafana.com/blog/2024/02/09/how-i-write-http-services-in-go-after-13-years/r)
- O exemplo define o
- Para validação, usa-se uma interface de método único
- A interface
Validatortem a formaValid(ctx context.Context) map[string]string - Se não houver problemas, é retornado um map de tamanho 0
- Campos problemáticos usam o nome do campo como key e uma descrição legível por humanos como value
- A interface
- A validação é adequada para checagens rápidas de campos
- Se campos obrigatórios não estão vazios
- Se uma string específica, como e-mail, está no formato correto
- Se números estão dentro do intervalo permitido
- Verificações mais complexas, como consultas ao banco de dados, são tratadas em outro local
- Essas verificações são importantes demais para ficarem escondidas dentro de uma função de validação rápida
- A versão genérica
decodeValid[T Validator]força o tipoTa implementarValidator - Chamar
len(problems)em um mapniltambém retorna 0, sem causar panic
Padrão de adaptador de middleware
- Um middleware recebe um
http.Handlere retorna um novohttp.Handler- Pode executar código antes ou depois de chamar o handler original
- Também pode deixar de chamar o handler original dependendo de uma condição
- No exemplo,
adminOnlyretornaHTTP 404 Not Foundquando o usuário não é administrador e não chama o handler original
- O local de aplicação de middlewares normalmente é
routes.go- Só de olhar a lista de endpoints, é possível saber quais middlewares estão aplicados a quais rotas
- Quando a lista de middlewares fica longa, dividi-la em várias linhas melhora a legibilidade
- Middlewares com muitas dependências são envolvidos por uma função que retorna o middleware
newMiddleware(logger, db, slackClient, rroll)retornafunc(http.Handler) http.Handler- No código de registro de rotas, o uso fica conciso, como
middleware(handleSomething(...)) - Também é possível definir um
type middleware func(h http.Handler) http.Handlerseparado, mas escrever diretamente o tipo de retorno deixa a leitura do código mais clara
Reduzir o escopo dos tipos de requisição/resposta
- Tipos de requisição/resposta usados apenas em um endpoint específico podem ser definidos dentro da função do handler
- O namespace global fica mais limpo
- Isso impede que outros handlers dependam de tipos que não têm garantia de estabilidade
- Pode haver atrito se o código de teste precisar do mesmo tipo
- Nesse caso, mover o tipo para fora também é justificável
- Quando os tipos de requisição/resposta ficam dentro do handler, os testes podem declarar uma nova struct anônima ou um tipo local
- Tipos locais em testes deixam a intenção explícita
- Por exemplo, se o endpoint
/greetprecisa apenas do campoName, e não de todoPerson, a struct de entrada do teste contém apenasName - Quem lê o teste entende imediatamente quais campos interessam àquele endpoint
- Por exemplo, se o endpoint
Inicialização preguiçosa com sync.Once
- Trabalhos caros durante a preparação de handlers são adiados para o momento da primeira requisição com
sync.Once- O tempo de inicialização da aplicação diminui
- Se o handler não for chamado, o trabalho caro não é executado
- O exemplo faz o parsing de arquivos de template apenas uma vez, na primeira requisição
sync.Oncegarante que o código seja executado somente uma vez- Outras requisições simultâneas aguardam até que a inicialização termine
- A verificação de erro é feita fora de
init.Dopara que o erro continue vindo à tona
- Essa abordagem desloca o tempo de inicialização do startup para o primeiro acesso ao endpoint em runtime
- Em ambientes que usam muito Google App Engine, essa abordagem pode ser adequada
- Dependendo do ambiente de deploy, é preciso decidir onde e quando usar
sync.Once
Estratégia de testes
- Essa estrutura tem a testabilidade como um objetivo importante
- A função
runpermite que o código de teste execute o programa diretamente - Os testes são avaliados por critérios como: se tornam o comportamento do programa fácil de entender, se reduzem o medo de quebrar algo ao mudar código e se dão confiança para implantar em produção depois de passarem
- A função
- Também é possível testar apenas handlers de forma isolada
- Chama-se a função de criação do handler e passam-se as dependências necessárias
httptest.NewRecorderehttp.NewRequestcompõem a requisição e a resposta- Código de status, corpo da resposta e headers são verificados
- Essa abordagem pula middlewares como autenticação e entra diretamente no código do handler
- A abordagem mais preferida é mais próxima de testes end-to-end
- Chama-se
runpara subir o programa de forma próxima à execução real - Inclui parsing de argumentos, conexão de dependências, migrações de banco de dados e inicialização do servidor
- Quando o teste chama a API, todas as camadas e também
routes.gosão validadas - Pode interagir com um banco de dados real
- Chama-se
- Essa abordagem ajuda a reduzir testes repetitivos
- Testar todas as camadas separadamente pode verificar o mesmo conteúdo várias vezes, com pequenas variações
- Testes end-to-end fornecem um conjunto central de testes que descreve a interação entre usuário e sistema
- Testes unitários já criados, por exemplo via TDD, podem ser mantidos quando fizer sentido; se repetirem o mesmo conteúdo dos testes end-to-end, podem ser removidos
- Cada teste pode executar sua própria instância do programa
- Cada teste passa argumentos, flags, entrada/saída padrão e variáveis de ambiente diferentes
- Cria-se uma função de cancelamento com
context.WithCancele registra-se emt.Cleanup(cancel) - Quando o teste termina, o contexto é cancelado e o programa é encerrado de forma graceful
- O
t.Cleanupdo Go 1.14 é usado como alternativa a escreverdeferdiretamente
Escopo de aplicação real e contexto organizacional
- Ao criar APIs simples, esse padrão busca código fácil de ler e de expandir
- É fácil copiar e expandir o padrão
- É fácil para pessoas novas trabalharem no código
- A insegurança ao fazer mudanças diminui
- A composição é explícita, sem comportamentos mágicos
- Mesmo usando ferramentas de geração de código, essa abordagem pode ser mantida
- Como exemplo, é possível usar o pacote Oto para gerar boilerplate baseado em templates
- Em projetos ou organizações grandes, escolhas tecnológicas existentes podem mudar a decisão
- Em organizações como a Grafana Labs, certas ferramentas e abstrações já podem ser amplamente usadas
- gRPC é um exemplo disso
- Quando há padrões estabelecidos e experiência acumulada, a escolha prática tende a seguir esse fluxo
- O contexto da família de produtos Grafana IRM também está incluído
- Grafana IRM é uma família de produtos em construção pela Grafana Labs
- Grafana Alerting envia alertas quando métricas saem dos limites permitidos
- Grafana OnCall automatiza o processo de contatar a pessoa adequada com escalas e regras de escalonamento
- Grafana Incident cria salas no Zoom, canais dedicados no Slack e timelines de eventos, ajudando na resposta a incidentes
- Itens em canais do Slack que recebem uma reação com emoji de rosto de robô são adicionados à timeline
1 comentários
Opiniões no Hacker News
Também já experimentei a abordagem de ter um validador separado, como um método
Valid, mas depois de ler “Parse, Don’t Validate”, da Lexi Lambda [0], passei a achar que usar o verificador de tipos do Go gera muito menos errosPor exemplo, se você quiser impedir que um usuário jamais defina um nome de usuário inválido contendo sinais de menor/maior, na abordagem com validador é preciso chamar o validador em todos os caminhos de código em que o nome de usuário vem de uma entrada não confiável
Em vez disso, se você tiver um tipo
Usernamee um construtorNewUsername(username string) (Username, error), o simples fato de existir um objetoUsernamejá garante que ele passou pela validação[0] https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va...
string; faça o parsing e atribua tiposEsse padrão permite fazer a validação tanto cedo quanto tarde, a qualquer momento, mas não diz quando ela deve ser feita. Em geral, muitas vezes é melhor fazê-la como parte do processo de parsing/validação de um objeto maior
Para a ideia de lidar com dados não validados no contexto de UI, vale consultar “I is for Intent”, de Steven Witten [1]
[1] https://acko.net/blog/i-is-for-intent/
Usernameestiver incluído dentro de uma struct e você esquecer de definir o valor, entra o zero value, que pode violar as restriçõesUma das abordagens que eu mais detesto é receber um objeto Config que representa a configuração do sistema inteiro e passá-lo de forma mutável por todo lado
Com isso, tudo fica acoplado por meio do objeto de configuração. Em um sistema, alguém escrevia novos valores no objeto de configuração recebido, e cada parte precisava ser configurada em uma ordem específica para funcionar corretamente
Em outro caso, um subsistema escrevia no objeto de configuração dados que outro leria depois, tornando impossível desativar uma parte do sistema
O padrão de “configuração como um grande valor mutável” é bastante incômodo não só em Go, mas também em outras linguagens
Em projetos Python, costumo usar bastante
dataclassde configuração imutável e passá-las para vários módulos; quando várias funções dependem de vários valores, em vez de passar cada um como argumento de função e definir tipos separadamente, ter todas as variáveis e definições de tipo em um únicodataclassacaba sendo um padrão de design bem convenienteOptionAs
optionsinternas só mudam durante a criação, e para fora são expostos apenasConfige acessores. Por exemplo, dá para criar algo comoconfig.New(config.Name("Emanon"))e ler comcfg.Name()Configpara cada pacote, e fazerconfigs.Configser uma composição dasConfigde cada pacoteTalvez não seja uma prática recomendada em Go, mas no início dá para representar a configuração do sistema inteiro como uma única entidade e, para cada pacote, passar apenas as dependências mínimas necessárias, o que é bom
Nos testes também fica um pouco mais fácil, porque não é preciso criar uma configuração inteira falsa só para testar um pacote
Ele nunca deve ser modificado. Como não dá para saber onde e como é usado, é uma pena o esforço desperdiçado; se uma mudança for necessária, é melhor criar um valor derivado a partir do original
O engraçado é que, por design, o objeto de configuração era imutável em certa medida, e para modificá-lo era preciso usar uma API
WARNING_DO_NOT_USE; mesmo assim, usei aquilo para alterar o objeto e causei o incidenteGosto muito do trabalho do Mat Ryer e, desde então, apliquei à maioria dos meus projetos Go as ideias da versão de 2018 deste texto
Mas sempre me incomodou o fato de
NewServerser um grande construtor que recebe todas as dependências como argumentos e, nos testes, recebernilpara dependências desnecessárias como sinal de que não serão usadasO resultado é que grandes partes do código acabam tendo muito estado compartilhado desnecessário. Na prática, há muitos handlers HTTP que só precisam verificar se o usuário da requisição pode acessar um recurso e chamar uma única função do repositório de dados, mas acabam virando parte de um bloco enorme que tem acesso a todos os objetos do servidor pai e ao repositório de dados inteiro
Mesmo querendo testar mockando só dois métodos, fica difícil escrever testes simples; o padrão do Mat Ryer é o melhor que já vi até hoje, mas ainda fica a sensação de que deve haver uma solução melhor
Quando possível, plugins são uma boa estratégia de fronteira de código. Uma arquitetura de plugins é, por padrão, opt-out, e só aparece quando é escolhida explicitamente, então não força todas as possibilidades sobre algum bloco de código
Tenho chamado software com essa característica de “à la carte”. Em geral, devemos evitar a situação de “fazer tudo para poder fazer qualquer coisa”
usersou um pacotecommentsEsses pacotes não têm nenhuma interface HTTP, mas cada um tem seu próprio
maine algum tipo de interface CLI. O comentário//go:build ignorenesses arquivos é útilEm vez de definir um handler como
func HandleX(w http.ResponseWriter, req *http.Request), faço algo no formatofunc HandleX(store *DataStore, dep1 Foo, dep2 Bar, commonDep Common) http.HandlerFunc, recebendo as dependências necessárias e retornando internamente ohttp.HandlerFuncrealDepois inicializo isso uma vez no ponto de entrada
NewServerestá fazendo coisas demais. É bem possível que tipos de dados e comportamentos demais estejam acopladosComo exemplo simples, se você adiciona um logger às dependências do construtor, o objeto passa a fazer um pouco mais do que a implementação simples inicial. Isso em si é aceitável, mas é uma pena se você não encontrar uma forma de fazer logging sem mexer na implementação do que era simples
Funções de ordem superior, por exemplo um decorador de logger, permitem composição, embora também tenham desvantagens. Ainda assim, é uma forma de estrutura administrável, não um erro
A ideia central é validar, dentro de
NewServer, os valores da struct de configuração opcional e então copiá-los para a struct do servidor. Assim, é possível mockar menos dependências, e os testes ficam muito mais fáceisTambém tentei bastante o padrão de opções funcionais, como muita gente recomenda, mas acabei desistindo. Achei um pouco inteligente demais e difícil de ler, além de ter mais boilerplate do que o padrão de struct de configuração + validação e cópia
[0] https://news.ycombinator.com/item?id=39320170
Gostaria que esta ideia fosse mais amplamente aceita em serviços HTTP de qualquer linguagem: se um handler precisa de dependências, ele deve pedi-las diretamente como argumentos, e não ficar como um método pendurado em uma struct de servidor que cria dependências-surpresa na hora de testar
Handlers de serviços HTTP geralmente contêm muita lógica de negócio, e essa lógica provavelmente terá muitas dependências. Na prática, vejo com frequência um único handler usando DB, cache, armazenamento de blobs, verificação de autorização específica do endpoint, verificador de licenças, fila, logger especial, cliente de métricas etc.
O número de parâmetros pode chegar a 9 ou mais, e linters ou regras práticas normalmente tentam impedir isso, mas as dependências não desaparecem; elas apenas ficam escondidas na classe/struct
server, enganando você com uma assinatura de método curta que parece ter poucas dependênciasCom o tempo, passei a achar melhor um código em que todas as dependências apareçam na assinatura da função/método, mesmo que virem 20. Assim, você não se engana sobre o aumento da complexidade do código
Por exemplo, coloco em uma struct
CreateUserapenas as dependências necessárias para aquela operação, comostore,cache,logger,pub, e implementoServeHTTPEm
main.goou no lugar que configura as dependências, crio cada operação e passo apenas as dependências necessárias. Gosto disso porque permite manter os métodos auxiliares de uma operação/handler específica como métodos privados daquela structPorém, se uma operação precisa de outra, pode ficar trabalhoso, porque você começa a passá-las entre si ou precisa extraí-las para um pacote/serviço separado
Por exemplo, usando algo como
handleHello({ db, cache, blobStore, authz }, req, res), se dois handlers usarem exatamente o mesmo contexto, ele pode ser reutilizado, e também fica fácil declarar, no ponto de chamada, o contexto específico de cada handlerConcordo com grande parte deste texto e gostaria de acrescentar alguns pontos
Se você passar um WaitGroup junto com o contexto da aplicação para a struct do serviço, uma interrupção pode acionar o encerramento da aplicação por meio do contexto, e a goroutine principal pode aguardar o WaitGroup antes de realmente sair
Em um programa CLI, é útil testar stdout, stdin, stderr, args, env etc., mas acho que isso vale menos para um servidor HTTP. Eu passaria uma configuração estruturada para a função
run, para que os testes fiquem mais focadosSou contra a ideia de fazer parsing de templates no handler com
sync.Once. Na minha visão, o handler não deveria fazer parsing de templates; isso deve acontecer na inicialização da aplicação. Se os templates não puderem ser parseados, a aplicação não deve estar pronta para receber requisições e deve terminar com código de saída diferente de 0Recentemente tenho brincado com ogen: https://github.com/ogen-go/ogen
Você escreve uma definição OpenAPI, e ele cuida de roteamento, definições de structs, validação de schema JSON etc. Minha única tarefa é implementar o serviço
Coisas como validação de faixa de inteiros em query string são muito tediosas, e, se eu escrever à mão, é fácil demais cometer typos
Ainda estou na fase de experimentar, então não encontrei pontos ruins
Escrever uma IDL parecida, como Protobuf ou capnproto, parece muito mais produtivo
[1] https://github.com/danielgtaylor/huma
[2] https://github.com/swaggest/rest
Achei que escrever a especificação seria tedioso, mas foi bem melhor do que eu esperava; de qualquer forma, como a especificação é necessária, acho melhor escrevê-la antes
Sinto que fx(https://github.com/uber-go/fx) é uma ferramenta muito simples e versátil para projetar aplicações
Os conselhos do texto continuam úteis, mas ela elimina completamente a parte de “como garantir que X esteja inicializado quando Y precisar dele”. O problema N*M vira um problema N: você só precisa se preocupar em como inicializar cada peça, sem se preocupar com a sincronização da inicialização entre elas
Já usei bastante bibliotecas de injeção de dependência em várias linguagens e também já implementei as minhas, mas a simplicidade e a generalidade do fx são as que mais me agradaram até agora
Em um sistema bem projetado, esse problema deveria ser trivial. Garantir que algo esteja inicializado quando você quiser usá-lo é uma questão de ele estar pronto para ser passado como argumento do construtor
Basta criar algo como
stockService := NewStockService(),orderService := NewOrderService(),orderProcessor := NewOrderProcessor(stockService, orderService)Não deveria haver necessidade de “sincronização” de inicialização, e, se você errar, não compila. Mesmo ao adicionar uma dependência circular, isso fica claro porque não dá para compor tudo na ordem correta
É um ótimo texto, com muitas ideias interessantes. Não acredito que eu não conhecia
signal.NotifyContextAgora vou conseguir lembrar como fazer tratamento de sinais sem copiar e colar em todo projeto
Gosto bastante da abordagem usada aqui, mas meus testes são um pouco diferentes
Em
newTestServer(), subo um servidor com dependências falsas injetadas e, se quero testar um erro de dependência, troco aquela propriedade por uma fake que retorna erroAssim consigo verificar caminhos de erro, entradas de log, emissão de métricas, timeouts e até graceful shutdown
Depois que o servidor inicia, verifico em qual porta ele fez bind. Como o padrão é
:0, é preciso esperar pela porta realmente alocadaTestes “unitários” podem ser feitos no nível do handler ou no nível HTTP, passando por todo o middleware ou por nenhum, e ainda assim testar suficientemente o código da forma como o usuário o verá. Também dá para subir N instâncias e testá-las em paralelo
Não uso Go, mas gosto desses padrões. Eles parecem se aplicar de forma bem universal a código testável
Não quero mais ver guias de início rápido, especialmente de Python, que tratam dependências de forma implícita/estática/impossível de testar