Added pgxmock for testing with required interface refactor

main
Drew Bednar 3 days ago
parent faca55f866
commit e8d81d467f

@ -2,10 +2,22 @@
![build-status](https://drone.runcible.io/api/badges/learning/pulley/status.svg)
A Golang HTTP API
## Local Development
This project uses air and a docker container for postgres to serve as the local development environment.
```bash
source .local.profile
make start-local
air
```
## Routes
Uses [CleanURLs](https://en.wikipedia.org/wiki/Clean_URL)

@ -114,7 +114,6 @@ func (app *application) getMovieHandler(w http.ResponseWriter, r *http.Request)
err = app.writeJSON(w, http.StatusOK, envelope{"movie": movie}, nil)
if err != nil {
//app.logger.Error(err.Error())
//http.Error(w, "The server encountered a problem and could not process your request", http.StatusInternalServerError)
app.serverErrorResponse(w, r, err)
}
@ -270,7 +269,7 @@ func (app *application) listMoviesHandler(w http.ResponseWriter, r *http.Request
return
}
movies, err := app.models.Movies.List(r.Context())
movies, err := app.models.Movies.GetAll(r.Context(), input.Title, input.Genres, input.Filters)
if err != nil {
app.serverErrorResponse(w, r, err)
return

@ -8,28 +8,43 @@ import (
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"git.runcible.io/learning/pulley/internal/assert"
"git.runcible.io/learning/pulley/internal/config"
"git.runcible.io/learning/pulley/internal/data"
"git.runcible.io/learning/pulley/internal/database"
"github.com/pashagolub/pgxmock/v4"
)
func newTestApplication() application {
func newTestApplication(pool database.PgxIface) application {
cfg := config.ServiceConfig{Env: "test"}
return application{config: cfg, logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
mockModels := data.NewModels(pool)
// Discards log output from tests
// logger := slog.New(slog.NewTextHandler(io.Discard, nil))
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug, AddSource: true}))
return application{config: cfg, logger: logger, models: mockModels}
}
func TestHealthRoute(t *testing.T) {
respRec := httptest.NewRecorder()
mockPool, err := pgxmock.NewPool()
if err != nil {
t.Fatal(err)
}
defer mockPool.Close()
r, err := http.NewRequest(http.MethodGet, "/v1/healthcheck", nil)
if err != nil {
t.Fatal(err)
}
app := newTestApplication()
app := newTestApplication(mockPool)
app.routes().ServeHTTP(respRec, r)
resp := respRec.Result()
@ -49,15 +64,40 @@ func TestHealthRoute(t *testing.T) {
func TestCreateMovieHandler(t *testing.T) {
respRec := httptest.NewRecorder()
mockPool, err := pgxmock.NewPool()
if err != nil {
t.Fatal(err)
}
defer mockPool.Close()
requestBody := `{"title": "Moana", "year": 2019, "runtime": "120 mins", "genres": ["family", "Samoan"]}`
movie := struct {
Title string `json:"title"`
Year int32 `json:"year"`
Runtime string `json:"runtime"`
Genres []string `json:"genres"`
}{
Title: "Moana",
Year: 2019,
Runtime: "120 mins",
Genres: []string{"family", "Samoan"},
}
mockPool.ExpectQuery("INSERT INTO movies").
// must use Runtime explicitly for args
WithArgs(movie.Title, movie.Year, data.Runtime(120), movie.Genres).
WillReturnRows(
pgxmock.NewRows([]string{"id", "created_at", "version"}).
AddRow(1, time.Now(), 1), // These values will be scanned into the struct
)
rquestBody, _ := json.Marshal(movie)
r, err := http.NewRequest(http.MethodPost, "/v1/movies", strings.NewReader(requestBody))
r, err := http.NewRequest(http.MethodPost, "/v1/movies", bytes.NewBuffer(rquestBody))
if err != nil {
t.Fatal(err)
}
app := newTestApplication()
app := newTestApplication(mockPool)
app.routes().ServeHTTP(respRec, r)
resp := respRec.Result()
@ -72,10 +112,20 @@ func TestCreateMovieHandler(t *testing.T) {
body = bytes.TrimSpace(body)
assert.StringContains(t, string(body), "Moana")
err = mockPool.ExpectationsWereMet()
if err != nil {
t.Errorf("there were unfulfilled expectations: %s", err)
}
}
// Consider simply testing app.jsonReader
func TestCreateMovieError(t *testing.T) {
func TestCreateMovieValidatorError(t *testing.T) {
mockPool, err := pgxmock.NewPool()
if err != nil {
t.Fatal(err)
}
defer mockPool.Close()
tests := []struct {
name string
@ -148,7 +198,7 @@ func TestCreateMovieError(t *testing.T) {
t.Fatal(err)
}
app := newTestApplication()
app := newTestApplication(mockPool)
app.routes().ServeHTTP(respRec, r)
@ -166,8 +216,15 @@ func TestCreateMovieError(t *testing.T) {
}
func TestGetAllMoviesHandler(t *testing.T) {
func TestGetMovieHandler(t *testing.T) {
mockPool, err := pgxmock.NewPool()
if err != nil {
t.Fatal(err)
}
defer mockPool.Close()
// TODO since this isn't a table test anymore should be able to refactor it
testTable := []struct {
name string
id string
@ -179,10 +236,53 @@ func TestGetAllMoviesHandler(t *testing.T) {
id: "1337",
wantCode: 200,
},
}
mockPool.ExpectQuery("SELECT id, created_at, title, year, runtime, genres, version FROM movies").
WithArgs(int64(1337)).WillReturnRows(
pgxmock.NewRows([]string{"id", "created_at", "title", "year", "runtime", "genres", "version"}).
AddRow(int64(1337), time.Now(), "a laura is born", 1990, 36, []string{"family", "wife"}, 1), // These values will be scanned into the struct
)
for _, test := range testTable {
t.Run(test.name, func(t *testing.T) {
respRec := httptest.NewRecorder()
r, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/v1/movies/%s", test.id), nil)
t.Logf("Path: %s, Method: %s", r.URL.Path, r.Method)
assert.NilError(t, err)
app := newTestApplication(mockPool)
// want to test with httprouter since we use it to parse context
app.routes().ServeHTTP(respRec, r)
resp := respRec.Result()
t.Logf("Code: %d", resp.StatusCode)
assert.Equal(t, resp.StatusCode, test.wantCode)
})
}
}
func TestGetMovieHandlerErrors(t *testing.T) {
mockPool, err := pgxmock.NewPool()
if err != nil {
t.Fatal(err)
}
defer mockPool.Close()
testTable := []struct {
name string
id string
wantCode int
useID bool
}{
// will redirect to /v1/movies/
{
name: "No ID provided",
id: "",
wantCode: 404,
wantCode: 301,
},
{
name: "Negative ID",
@ -199,7 +299,7 @@ func TestGetAllMoviesHandler(t *testing.T) {
t.Logf("Path: %s, Method: %s", r.URL.Path, r.Method)
assert.NilError(t, err)
app := newTestApplication()
app := newTestApplication(mockPool)
// want to test with httprouter since we use it to parse context
app.routes().ServeHTTP(respRec, r)

@ -33,7 +33,7 @@ const Version = "1.0.0"
type application struct {
config config.ServiceConfig
logger *slog.Logger
models data.Models
models *data.Models
}
func run(ctx context.Context, w io.Writer, args []string) error {

@ -18,6 +18,7 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/pashagolub/pgxmock/v4 v4.9.0 // indirect
go.uber.org/atomic v1.7.0 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/sync v0.13.0 // indirect

@ -28,6 +28,8 @@ github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dv
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/pashagolub/pgxmock/v4 v4.9.0 h1:itlO8nrVRnzkdMBXLs8pWUyyB2PC3Gku0WGIj/gGl7I=
github.com/pashagolub/pgxmock/v4 v4.9.0/go.mod h1:9L57pC193h2aKRHVyiiE817avasIPZnPwPlw3JczWvM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

@ -3,7 +3,7 @@ package data
import (
"errors"
"github.com/jackc/pgx/v5/pgxpool"
"git.runcible.io/learning/pulley/internal/database"
)
var (
@ -16,8 +16,8 @@ type Models struct {
Movies MovieModel
}
func NewModels(pool *pgxpool.Pool) Models {
return Models{
Movies: MovieModel{pool: pool},
func NewModels(pool database.PgxIface) *Models {
return &Models{
Movies: MovieModel{db: pool},
}
}

@ -5,10 +5,10 @@ import (
"errors"
"time"
"git.runcible.io/learning/pulley/internal/database"
"git.runcible.io/learning/pulley/internal/validator"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// MUST export fields to serialize them
@ -24,13 +24,13 @@ type Movie struct {
// be considered zero and omitted -- and the MarshalJSON() method we just made
// won't be called at all.
// VSCode will complain though about reflection issue
Runtime Runtime `json:"runtime,omitzero`
Runtime Runtime `json:"runtime,omitzero"`
Genres []string `json:"genres,omitzero"`
Version int32 `json:"version"`
}
type MovieModel struct {
pool *pgxpool.Pool
db database.PgxIface
}
func (m MovieModel) Insert(ctx context.Context, movie *Movie) error {
@ -41,7 +41,7 @@ func (m MovieModel) Insert(ctx context.Context, movie *Movie) error {
args := []any{movie.Title, movie.Year, movie.Runtime, movie.Genres}
row := m.pool.QueryRow(ctx, query, args...)
row := m.db.QueryRow(ctx, query, args...)
// Insert is mutating the Movie struct
err := row.Scan(&movie.ID, &movie.CreatedAt, &movie.Version)
return err
@ -68,7 +68,7 @@ func (m MovieModel) Get(ctx context.Context, id int64) (*Movie, error) {
var movie Movie
err := m.pool.QueryRow(ctx, query, id).Scan(
err := m.db.QueryRow(ctx, query, id).Scan(
// &[]byte{}, // throwaway the pg_sleep value
&movie.ID,
&movie.CreatedAt,
@ -115,7 +115,7 @@ func (m MovieModel) Update(ctx context.Context, movie *Movie) error {
}
// Will not return any rows if the version number has already changed.
err := m.pool.QueryRow(ctx, query, args...).Scan(&movie.Version)
err := m.db.QueryRow(ctx, query, args...).Scan(&movie.Version)
if err != nil {
switch {
case errors.Is(err, pgx.ErrNoRows):
@ -142,7 +142,7 @@ func (m MovieModel) Delete(ctx context.Context, id int64) (err error) {
query := `DELETE FROM movies WHERE id = $1`
tx, err := m.pool.BeginTx(ctx, pgx.TxOptions{})
tx, err := m.db.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
}
@ -169,14 +169,21 @@ func (m MovieModel) Delete(ctx context.Context, id int64) (err error) {
return nil
}
func (m MovieModel) List(ctx context.Context) ([]*Movie, error) {
func (m MovieModel) GetAll(ctx context.Context, title string, genres []string, filters Filters) ([]*Movie, error) {
query := `
SELECT id, created_at, title, year, runtime, genres, version
FROM movies
ORDER BY id ASC
`
rows, err := m.pool.Query(ctx, query)
// ctx want some timeout for queries. When used in the handler the context passed should
// be the r.Context. Since cancel functions are inherited it will cancel on client
// disconnect or on time out.
ctxTimeout, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
rows, err := m.db.Query(ctxTimeout, query)
if err != nil {
return nil, err
}

@ -6,6 +6,7 @@ import (
"time"
"git.runcible.io/learning/pulley/internal/config"
pgx "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
@ -35,3 +36,26 @@ func OpenPgPool(ctx context.Context, cfg config.ServiceConfig) (*pgxpool.Pool, e
}
return pool, nil
}
type PgxIface interface {
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
BeginTx(ctx context.Context, txOptions pgx.TxOptions) (pgx.Tx, error)
}
// TODO determine if this is even useful. Sqlc does produce
type DBQuierier struct {
db *pgxpool.Pool
}
func (q *DBQuierier) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
return q.db.Query(ctx, sql, args...)
}
func (q *DBQuierier) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
return q.db.QueryRow(ctx, sql, args...)
}
func (q *DBQuierier) BeginTx(ctx context.Context, txOptions pgx.TxOptions) (pgx.Tx, error) {
return q.db.BeginTx(ctx, txOptions)
}

@ -4,7 +4,7 @@
title text NOT NULL,
year integer NOT NULL,
runtime integer NOT NULL,
genres text[] NOT NULL,
version integer NOT NULL DEFAULT 1
);

Loading…
Cancel
Save