Disponibilizando o meu primeiro pacote open source em Go! Configurando e fazendo os ajustes finais no repositório do go-lazuli

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, incluindo context.Context, identifier, e password.
  • out: define os dados de saída esperados, como a resposta authResponse do tipo bsky.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, e password.
  • out: define os valores esperados para authResponse e err 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 em httptest.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 de InternalServerError.
  • 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 o defer 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 que result seja nil, e verifica o tipo e valor do erro retornado.
  • Se não há erro esperado, verifica-se que result corresponde ao valor authResponse 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 que err é 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:

  1. Quando há um push na branch main.
  2. 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.

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 como on 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.

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ão v1.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, como threshold-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!