Disponibilizando o meu primeiro pacote open source em Go! Configurando e fazendo os ajustes finais no repositório do go-lazuli
Buenas para você que está lendo mais um artigo! Hoje vamos dar continuidade à série de criação de um pacote Go open-source! No artigo de hoje, vamos aprender a criar testes unitários para os arquivos do pacote, adicionar o Github Actions no repositório para realizar verificações de segurança, testes e cobertura, gerar versão no Github e disponibilizar tudo isso no pkg.go.dev!
Mas antes de começarmos, gostaria de dizer que fiz algumas mudanças no pacote que não vou comentar aqui, mas você pode ver mais no repositório do Github, ok?! Outra consideração é que, alguns ajustes que fiz no repositório como configuração de regras de branches não estarei comentando aqui para não alongar demais o artigo. Vamos começar?
Criando os arquivos de teste
Para começar, irei mostrar como escrevi 2 dos 3 arquivos de teste e explicar cada parte do código aqui. O arquivo de teste do client deixarei como exercício para você entender, mas já deixo uma dica: use o arquivo de teste do session como base, que você vai entender bem.
Arquivo de teste do session
Criando o arquivo de teste session_test.go
com o seguinte conteúdo:
package lazuli
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/augustoasilva/go-lazuli/pkg/lazuli/bsky"
"github.com/stretchr/testify/assert"
)
func TestClient_CreateSession(t *testing.T) {
type in struct {
ctx context.Context
identifier string
password string
}
type out struct {
authResponse *bsky.AuthResponse
err error
}
tests := []struct {
name string
in in
out out
handler http.HandlerFunc
}{
{
name: "Given valid input and successful response, When CreateSession is called, Then it should return auth response",
in: in{
ctx: context.Background(),
identifier: "test-user",
password: "test-password",
},
out: out{
authResponse: &bsky.AuthResponse{AccessJwt: "valid-token"},
err: nil,
},
handler: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(bsky.AuthResponse{AccessJwt: "valid-token"})
},
},
{
name: "Given invalid input, When CreateSession is called, Then it should return an error",
in: in{
ctx: context.Background(),
identifier: "test-user",
password: "wrong-password",
},
out: out{
authResponse: nil,
err: &Error{
Code: http.StatusUnauthorized,
Message: "create session request failed",
Details: `{"message":"Unauthorized"}` + "\\n",
},
},
handler: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]string{"message": "Unauthorized"})
},
},
{
name: "Given server error, When CreateSession is called, Then it should return an internal server error",
in: in{
ctx: context.Background(),
identifier: "test-user",
password: "test-password",
},
out: out{
authResponse: nil,
err: &Error{
Code: http.StatusInternalServerError,
Message: "error to decode json",
Details: "unexpected EOF",
},
},
handler: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{`)) // invalid JSON
},
},
{
name: "Given no response from server, When CreateSession is called, Then it should return an internal server error",
in: in{
ctx: context.Background(),
identifier: "test-user",
password: "test-password",
},
out: out{
authResponse: nil,
err: &Error{
Code: http.StatusInternalServerError,
Message: "fail to create session request struct",
Details: `parse ":invalid-url/com.atproto.server.createSession": missing protocol scheme`,
},
},
handler: nil, // simulate no response
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(tt.handler)
defer server.Close()
lazuliClient := &client{
xrpcURL: server.URL,
httpClient: server.Client(),
}
if tt.handler == nil {
lazuliClient.xrpcURL = ":invalid-url"
}
result, err := lazuliClient.CreateSession(tt.in.ctx, tt.in.identifier, tt.in.password)
if tt.out.err != nil {
assert.Nil(t, result)
var actualErr *Error
assert.ErrorAs(t, err, &actualErr)
assert.Equal(t, tt.out.err, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.out.authResponse, result)
}
})
}
}
Esse arquivo testa a função CreateSession
, que cria uma nova sessão autenticada para o usuário do arquivo session.go que vimos no artigo passado. Ele utiliza o pacote testing
do Go e a biblioteca testify/assert
para simplificar as asserts no teste. Abaixo, vou explicar cada seção do código.
1. Função de Teste TestClient_CreateSession
A função TestClient_CreateSession
é onde o teste é configurado. Dentro dela, o código define uma estrutura de dados para facilitar a organização dos testes e criar os cenários desejados.
2. Estrutura de Entrada e Saída
Dentro da função de teste, são definidos dois tipos de estruturas internas (in
e out
):
in
: especifica os dados de entrada, incluindocontext.Context
,identifier
, epassword
.out
: define os dados de saída esperados, como a respostaauthResponse
do tipobsky.AuthResponse
e qualquer erro esperado (err
).
3. Definição dos Cenários de Teste
Uma lista de cenários de teste é criada usando a estrutura tests
. Cada item na lista representa um caso de teste individual. Cada cenário tem os seguintes campos:
- name: descreve o teste em linguagem natural, por exemplo,
"Given valid input and successful response, When CreateSession is called, Then it should return auth response"
. - in: especifica os valores de entrada específicos para o teste, como
ctx
,identifier
, epassword
. - out: define os valores esperados para
authResponse
eerr
quando o teste for executado. - handler: uma função HTTP simulada para emular diferentes respostas do servidor. Esse
handler
define como o servidor responderá, e é usado emhttptest.NewServer()
para criar um servidor HTTP falso que retorna essas respostas.
Explicação dos Cenários:
- O primeiro cenário é o caso ideal: com entradas válidas, o
CreateSession
deve retornar uma resposta de autenticação válida. - O segundo cenário testa um erro de autenticação quando as credenciais são inválidas, esperando um erro
StatusUnauthorized
. - O terceiro cenário simula um erro no servidor enviando uma resposta JSON inválida, testando se
CreateSession
retorna um erro deInternalServerError
. - O quarto cenário simula um URL inválido para o cliente, o que deve resultar em um erro de URL malformado.
4. Laço de Testes for _, tt := range tests
Um laço for
percorre cada caso de teste (tt
), onde cada teste é executado usando t.Run(tt.name, func(t *testing.T) { ... })
, que cria um subteste individual. Dentro de cada subteste:
- Um servidor de teste é criado com o
httptest.NewServer(tt.handler)
, e odefer server.Close()
garante que o servidor seja fechado ao final do teste. - Um cliente (
lazuliClient
) é configurado para usar o servidor de teste.
Para o caso de URL inválido, lazuliClient.xrpcURL
é ajustado para um valor inválido ":invalid-url"
.
5. Execução e Verificação dos Resultados
A função CreateSession
é chamada com os valores de entrada definidos. Em seguida, os valores retornados são verificados com asserções (assert
):
- Se um erro é esperado (
tt.out.err != nil
), o teste espera queresult
sejanil
, e verifica o tipo e valor do erro retornado. - Se não há erro esperado, verifica-se que
result
corresponde ao valorauthResponse
esperado.
Gostou da explicação até aqui? Eu detalhei bastante esse arquivo para entender como eu gosto de escrever meus testes e como que fiz aqui. Agora vamos partir para o próximo!
Arquivo de teste da firehose
Crie o arquivo firehose_test.go
e copie o seguinte conteúdo abaixo. Logo em seguida eu irei explicar novamente cada passo que fiz neste arquivo de teste.
package lazuli
import (
"context"
"net/http"
"testing"
"github.com/augustoasilva/go-lazuli/pkg/lazuli/bsky"
"github.com/fxamacker/cbor/v2"
"github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"net/http/httptest"
)
// MockHandlerCommitFn is a mock implementation of HandlerCommitFn.
type MockHandlerCommitFn struct {
mock.Mock
}
func (m *MockHandlerCommitFn) Handle(evt bsky.CommitEvent) error {
args := m.Called(evt)
return args.Error(0)
}
func TestClient_ConsumeFirehose(t *testing.T) {
type in struct {
ctx context.Context
events []bsky.CommitEvent
handler *MockHandlerCommitFn
shouldBreakCBORDecoder bool
}
type out struct {
err error
}
tests := []struct {
name string
in in
setup func(events []bsky.CommitEvent, handler *MockHandlerCommitFn)
out out
}{
{
name: "Given valid events, When ConsumeFirehose is called, Then it should process the events successfully",
in: in{
ctx: context.Background(),
events: []bsky.CommitEvent{
bsky.RepoCommitEvent{},
},
handler: &MockHandlerCommitFn{},
},
setup: func(events []bsky.CommitEvent, handler *MockHandlerCommitFn) {
for _, event := range events {
handler.On("Handle", event).Return(nil)
}
},
out: out{
err: nil,
},
},
{
name: "Given an invalid event, When ConsumeFirehose is called, Then it should return an error",
in: in{
ctx: context.Background(),
events: []bsky.CommitEvent{
bsky.RepoCommitEvent{},
},
handler: &MockHandlerCommitFn{},
shouldBreakCBORDecoder: true,
},
setup: func(events []bsky.CommitEvent, handler *MockHandlerCommitFn) {
for _, event := range events {
handler.On("Handle", event).Return(nil)
}
},
out: out{
err: newError(http.StatusInternalServerError, "fail to decode repo commit event message", `cbor: unexpected "break" code`),
},
},
{
name: "Given a handler error, When ConsumeFirehose is called, Then it should return the handler's error",
in: in{
ctx: context.Background(),
events: []bsky.CommitEvent{
bsky.RepoCommitEvent{},
},
handler: &MockHandlerCommitFn{},
},
setup: func(events []bsky.CommitEvent, handler *MockHandlerCommitFn) {
for _, event := range events {
handler.On("Handle", event).Return(newError(http.StatusInternalServerError, "handler error", "handler error"))
}
},
out: out{
err: newError(http.StatusInternalServerError, "handler error", "handler error"),
},
},
{
name: "Given a websocket connection error, When ConsumeFirehose is called, Then it should return a connection error",
in: in{
ctx: context.Background(),
events: nil, // No events to process for this test case
handler: &MockHandlerCommitFn{},
},
out: out{
err: newError(http.StatusInternalServerError, "fail to connect to websocket", "malformed ws or wss URL"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var server *httptest.Server
if len(tt.in.events) > 0 {
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upgrader := websocket.Upgrader{}
conn, err := upgrader.Upgrade(w, r, nil)
require.NoError(t, err)
defer conn.Close()
for _, event := range tt.in.events {
message, cborErr := cbor.Marshal(event)
require.NoError(t, cborErr)
writeErr := conn.WriteMessage(websocket.BinaryMessage, message)
require.NoError(t, writeErr)
}
if tt.in.shouldBreakCBORDecoder {
err = conn.WriteMessage(websocket.BinaryMessage, []byte{'\\xFF'}) // Invalid CBOR to trigger decode error
require.NoError(t, err)
}
}))
} else {
server = httptest.NewUnstartedServer(nil)
}
defer server.Close()
wsURL := "ws"
if len(server.URL) > 4 {
wsURL = wsURL + server.URL[4:] + "/ws"
}
lazuliClient := &client{
wsURL: wsURL,
wsDialer: websocket.DefaultDialer,
}
if tt.setup != nil && tt.in.handler != nil {
tt.setup(tt.in.events, tt.in.handler)
}
err := lazuliClient.ConsumeFirehose(tt.in.ctx, tt.in.handler.Handle)
if tt.out.err != nil {
assert.Error(t, err)
assert.Equal(t, tt.out.err, err)
} else {
assert.NoError(t, err)
}
if tt.in.handler != nil {
tt.in.handler.AssertExpectations(t)
}
})
}
}
Pois bem, esse arquivo configura testes para a função ConsumeFirehose
, simulando diferentes cenários para verificar se a função lida corretamente com os eventos recebidos e erros potenciais. Vamos começar a entender como o teste foi feito!
1. Mock do HandlerCommitFn
A struct MockHandlerCommitFn
é um mock da função Handle
, usando a biblioteca testify
para simular o comportamento do handler. É nela que você define o que deve acontecer quando o handler recebe um evento. No caso aqui, ele tem um método Handle
que recebe um CommitEvent
e retorna o que foi definido no mock.
2. Função de Teste TestClient_ConsumeFirehose
A função principal do teste é TestClient_ConsumeFirehose
. Ela configura todos os casos de teste com diferentes entradas, saídas esperadas e uma função setup
para configurar o mock e simular os eventos.
3. Estruturas in
e out
As estruturas in
e out
definem os dados de entrada e saída para cada cenário:
in
inclui:ctx
: o contexto do Go.events
: lista de eventos que serão processados.handler
: a função mockada que vai lidar com cada evento.shouldBreakCBORDecoder
: booleano que indica se o teste deve gerar um erro de CBOR intencional para verificar o tratamento de erro.
out
contém o erro esperado (err
) quando algo der errado.
4. Configuração dos Cenários de Teste
A lista tests
define diferentes cenários, cada um como um caso específico:
- "Valid Events": cenário em que a função
ConsumeFirehose
recebe eventos válidos e deve processá-los com sucesso. - "Invalid Event": aqui, o
shouldBreakCBORDecoder
étrue
, o que simula um erro intencional de CBOR para verificar se o erro de decodificação é tratado. - "Handler Error": simula um erro do próprio handler (não do WebSocket ou da decodificação), e o teste espera que esse erro específico seja retornado.
- "WebSocket Connection Error": simula um erro ao conectar ao WebSocket e verifica se o erro de conexão é tratado corretamente.
5. Execução dos Testes com t.Run
Para cada caso de teste em tests
, t.Run
é chamado, criando subtestes individuais para cada cenário:
- Um servidor de teste (
httptest.NewServer
) é configurado para simular um servidor WebSocket que responde com eventos em CBOR. - Se
shouldBreakCBORDecoder
étrue
, um valor inválido de CBOR é enviado para simular um erro de decodificação. - A URL do WebSocket (
wsURL
) é configurada para apontar para o servidor falso.
6. Chamando e Verificando ConsumeFirehose
Depois da configuração, ConsumeFirehose
é chamada com o contexto e o mock do handler. Os resultados são verificados com assert
:
- Se um erro é esperado (
tt.out.err != nil
),assert.Error
é usado para verificar se o erro de saída corresponde ao erro esperado. - Se nenhum erro é esperado,
assert.NoError
verifica queerr
énil
.
Por fim, AssertExpectations
verifica se o mock handler
foi chamado conforme configurado na função setup
.
Acho que é isso sobre os arquivos de testes, deu pra mostrar como lidar com testes que usam requisição http e lidam com conexão de websocket. Agora em seguinda vamos criar as actions do Github para poder validar segurança e testes do pacote, vamos lá?
Adicionando o arquivo de workflow do github actions.
Vamos criar um arquivo de workflow dentro da pasta .github/workflows/ci-workflow.yml. Dentro dele vamos ter o seguinte conteúdo:
name: CI
on:
push:
branches:
- 'main'
pull_request:
branches:
- 'main'
jobs:
vuln_check:
name: "Go Vulncheck"
runs-on: ubuntu-latest
steps:
- id: govulncheck
uses: golang/govulncheck-action@v1
with:
go-version-input: 1.23
go-package: ./...
sec:
name: "Go Sec"
needs: vuln_check
runs-on: ubuntu-latest
env:
GO111MODULE: on
steps:
- name: Checkout Source
uses: actions/checkout@v3
- name: Run Gosec Security Scanner
uses: securego/gosec@master
with:
args: ./...
golangci-lint:
name: "GolangCI Lint"
needs: sec
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: stable
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: v1.61
tests:
name: "Go Test Coverage"
needs: golangci-lint
runs-on: ubuntu-latest
permissions: write-all
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
- name: Generate Test Coverage
run: go test ./... -coverprofile=./cover.out -covermode=atomic -coverpkg=./...
- name: Check Test Coverage
uses: vladopajic/go-test-coverage@v2
with:
# Configure action using config file (option 1)
config: ./.github/workflows/coverage/config.yml
git-token: ${{ github.ref_name == 'main' && secrets.GITHUB_TOKEN || '' }}
git-branch: badges
# Configure action by specifying input parameters individually (option 2).
# If you are using config file (option 1) you shouldn't use these parameters, however
# specifing these action parameters will override appropriate config values.
# profile: cover.out
# local-prefix: github.com/org/project
# threshold-file: 80
# threshold-package: 80
# threshold-total: 95
Dessa forma estamos criando um Workflow chamado CI (Continuous Integration) que é acionado automaticamente em dois casos:
- Quando há um push na branch
main
. - Quando um pull request é aberto na branch
main
.
Ele é dividido em quatro "jobs" principais, que são como etapas do processo, e cada uma realiza uma tarefa específica: verificação de vulnerabilidades, análise de segurança, verificação de estilo de código e execução de testes com cobertura de código.
Mas espera lá, antes de explicar cada job, você não sabe o que é CI? Deixa que eu te explico agora! CI significa Continuous Integration, ou Integração Contínua. Basicamente, é uma prática onde, toda vez que uma nova parte do código é adicionada ao projeto, ele é automaticamente testado, verificado e integrado. A ideia é encontrar problemas cedo, antes que o código seja liberado para produção. Esse processo ajuda a garantir que, mesmo com várias pessoas mexendo no código, ele sempre esteja em um estado funcional e consistente. E, claro, tudo isso é feito com workflows como o que estamos usando aqui, onde cada etapa é automatizada para verificar segurança, estilo de código e qualidade!
Agora vamos continuar, explicando cada job do workflow, que irá garantir um padrão para o projeto.
1. Job: vuln_check
- Verificação de Vulnerabilidades
Esse job faz uma checagem para identificar vulnerabilidades conhecidas no código usando o Go Vulncheck:
- runs-on: roda em um ambiente
ubuntu-latest
. - steps:
- Usa o
golang/govulncheck-action
para escanear o código. Configuramos a versão do Go (go-version-input
) para a versão 1.23 e indicamos o pacote a ser verificado (go-package: ./...
), que inclui todos os arquivos do projeto.
- Usa o
2. Job: sec
- Segurança com Gosec
Esse segundo job roda só depois do vuln_check
(graças ao parâmetro needs: vuln_check
), e faz uma análise de segurança com a ferramenta Gosec:
- runs-on: também usa o
ubuntu-latest
. - env: define a variável
GO111MODULE
comoon
para assegurar que o Go opere no modo de módulos. - steps:
- Faz o checkout do código-fonte com o
actions/checkout
. - Executa o Gosec Security Scanner com o
securego/gosec@master
, que escaneia todos os arquivos (args: ./...
) em busca de possíveis falhas de segurança.
- Faz o checkout do código-fonte com o
3. Job: golangci-lint
- Verificação de Estilo com GolangCI Lint
Este job roda após o sec
e verifica se o código segue os padrões e estilos definidos para projetos em Go:
- needs: depende do job
sec
. - steps:
- Faz o checkout do código.
- Configura a versão estável do Go com o
actions/setup-go@v5
. - Roda o GolangCI Lint (
golangci/golangci-lint-action@v6
) na versãov1.61
, que aplica as regras de estilo e boas práticas definidas pelo GolangCI Lint.
4. Job: tests
- Cobertura de Testes
Este último job roda após o golangci-lint
e executa os testes de unidade, gerando uma cobertura de código:
- permissions: define
write-all
para permitir que o workflow faça atualizações, se necessário. - steps:
- Faz o checkout e configura o Go.
- Generate Test Coverage: Roda os testes (
go test ./...
) e gera um relatório de cobertura (coverprofile=./cover.out
). - Check Test Coverage: Utiliza o
vladopajic/go-test-coverage
para avaliar se o nível de cobertura do código atende aos valores mínimos definidos. Ele pode ser configurado via arquivo.yml
(config: ./.github/workflows/coverage/config.yml
) ou por parâmetros individuais, comothreshold-total: 95
, que indicaria uma cobertura mínima de 95% para o projeto.
Gerando a primeira versão do pacote
Ufa, estamos quase lá! Já adicionamos testes e a integração contínua no nosso repositório. Agora, vamos gerar nossa primeira release manualmente! Mas um detalhe aqui: dá para automatizar essa parte também via Github Actions, porém, como estou bem no início desse pacote, vou manter manual mesmo. Quem sabe mais pra frente eu automatize.
Bem, para isso, na página inicial do repositório do projeto, veremos, do lado direito, a seção de Releases, onde iremos clicar em Create new release. Vou colocar abaixo a imagem para você dar uma olhadinha:
Depois disso, entraremos na tela de edição de release, conforme imagem abaixo. Nessa tela é bem simples: vamos selecionar uma tag (Choose a tag). Caso não exista, ao digitar uma versão para a tag, ele já vai gerar uma para você na hora. Depois, você seleciona qual a branch que ele vai usar de base (Target). Dá até para gerar o changelog automaticamente via o botão Generate release notes. E tá pronto o sorvetinho!
Após criado, conseguimos ver na página de Releases a primeira release criada do pacote go-lazuli!
Adicionando o pacote no pkg.go.dev
Bom, para disponibilizar o pacote recém-criado no pkg.go.dev, existem algumas formas, mas irei usar a mais simples (ao meu ver), que é nada mais, nada menos que acessar a página do pacote, que seria pkg.go.dev + módulo/do/pacote. No caso, ficaria assim: pkg.go.dev/github.com/augustoasilva/go-lazuli. Ao acessar, ela não vai encontrar o pacote e vai aparecer um botão no meio da tela escrito Request + módulo/do/pacote; veja abaixo na imagem.
É isso! Depois de algumas horas, ele vai indexar o pacote recém-criado, e poderemos ver as informações dele. Saca só como fica na imagem abaixo.
Próximos passos
Bom, é isso (eu acho) sobre a criação e compartilhamento de um pacote open-source em Go. Agora, irei dar continuidade ao projeto que estou fazendo, que vai usar essa nova biblioteca para processar os eventos do Bluesky via AT Proto e que vamos utilizar em conjunto com o RabbitMQ. Então, até o próximo artigo!