httpbaselinetest

package module
v0.0.0-...-44eb072 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 5, 2022 License: Apache-2.0 Imports: 18 Imported by: 0

README

httpbaselinetest

Think of your HTTP service as a state machine. A HTTP request is an event that results in an HTTP response and a new state in your database.

The httpbaselinetest package provides a framework for recording requests, responses, and the expected database changes.

Currently only works with postgresql.

Example


setupFunc := func(name string, btest *httpbaselinetest.HTTPBaselineTest) error {
  // Create your http handler here
  myserver := myhttp.NewServer()
  btest.Handler = myserver
  // Tell httpbaselinetest to use the db connection.
  // More on this feature later
  btest.Db = myserver.Db()
  return nil
}
teardownFunc := func(t *testing.T, btest *httpbaselinetest.HTTPBaselineTest) error {
  // Maybe clean something up?
  return nil
}
bts := httpbaselinetest.NewDefaultSuite(t)
bts.Run("POST v1 car with auth", httpbaselinetest.HTTPBaselineTest{
    Setup: setupFunc,
    Teardown: teardownFunc,
    Method:  http.MethodPost,
    Path:    "/api/v1/car",
    Body: map[string]string{
        "make":      "Honda",
        "model":     "Accord",
        "modelYear": "2020",
        "color":     "red",
    },
    Headers: map[string]string{
        "Authorization": "MySecret",
        "Content-Type": "application/json",
    },
    Tables: []string{"cars"},
})

First, generate a set of baselines

$ REBASELINE=1 go test ./pkg/... \
  -run TestBaselines/POST_v1_car_with_auth -count=1

Now, run your baseline tests to make sure nothing has changed

$ go test ./pkg/... \
  -run TestBaselines/POST_v1_car_with_auth -count=1

What happens if your baseline doesn't match? Here the request has been changed:

$ go test ./pkg/... \
  -run TestBaselines/POST_v1_car_with_auth -count=1
--- FAIL: TestBaselines (0.01s)
--- FAIL: TestBaselines/POST_v1_car_with_auth (0.01s)
    suite.go:222: Request Difference
    suite.go:223:
        --- testdata/post_v1_car_with_auth.resp.txt (expected)
        +++ actual
        @@ -5 +5 @@
        -  "modelYear": "2020",
        +  "modelYear": "1999",
        @@ -11 +10,0 @@
        -

Let's look at the generated files from when REBASELINE was configured.

Request File
# .../mytestpkg/testdata/post_v1_car_with_auth.req.txt
POST /api/v1/car HTTP/1.1
Host: example.com
Authorization: MySecret
Content-Type: application/json
Content-Length: 83

{
  "color": "red",
  "make": "Honda",
  "model": "Accord",
  "modelYear": "2020"
}
Response File
# .../mytestpkg/testdata/post_v1_car_with_auth.resp.txt
HTTP/1.1 200 OK
Content-Type: application/json

{
  "color": "red",
  "id": "8fd7f84c-ce1c-463c-ba3f-ea81725f1eb4",
  "make": "Honda",
  "model": "Accord",
  "modelYear": "2020",
  "owner_id": "7f2272e3-f287-49d8-a384-4ca18b84c98f"
}
DB File
# .../mytestpkg/testdata/post_v1_car_with_auth.db.json
{
  "cars": {
    "numRowsInserted": 1,
    "numRowsUpdated": 0,
    "numRowsDeleted": 0,
    "removedRows": [],
    "addedRows": [
      {
        "color": "red",
        "id": "8fd7f84c-ce1c-463c-ba3f-ea81725f1eb4",
        "make": "Honda",
        "model": "Accord",
        "modelYear": "2020",
        "owner_id": "7f2272e3-f287-49d8-a384-4ca18b84c98f"
      }
    ]
  }
}

Database Seeding

You may need to get the database into an appropriate state before running your tests.

Seed File

You can configure a YAML Seed file that will be loaded with polluter. This gives you maximum control over exactly what is in the db, plus gives a text file can that be easily compared for changes.

Seed Function

You can also configure a SeedFunc to load seed data. The HttpBaselineTest is passed as an argument to the function so you can use the already established database connection. If you want, you can run your tests with both a Seed and a SeedFunc. The SeedFunc will be run first.

Finally, if the REGENERATE_SEED environment variable is set and both a Seed and SeedFunc are provided, the Seed will be overwritten after running SeedFunc. This lets you create your Seed file data programmatically and the dump to YAML for more customization.

Database Baselines

The database baseline feature expects to be run inside a transaction. It then uses pg_stat_xact_user_tables to track which tables have changes. For tests that do expect database changes, the HttpBaselineTest.Tables field should be set so that a baseline of expected database changes can be created. If your baseline test makes changes to a table that is not configured, the test will fail.

Testing with Transactions

Use go-txdb to have all of your baseline tests run in a separate transaction so that any changes are discarded at the end of the test.

You can create a separate fake database name for each test to ensure a new connection is created.

go-txdb basic example
import (
	"github.com/DATA-DOG/go-txdb"
)
// ...

realDbUrl := "postgres://user:pass@dbhost:5432/my_test_db?sslmode=disable"
txdb.Register("pgx", "postgres", realDbUrl)
setupFunc := func(testName string, btest *httpbaselinetest.HttpBaselineTest) error {
  normalizedTestName := httpbaselinetest.NormalizeTestName(testName)
  testDbUrl := "pgx://user:pass@" + "txdb_" + normalizedTestName +
    " :5432/?sslmode=disable"
  server := myhttpserverpkg.NewServer(testDbUrl)
  btest.Handler = server
  btest.db = server.Db()
}
go-txdb Pop example

pop makes this a bit more exciting. This example is for v4.13.1 where you have to create your own pop.store.

type BaselinePopStore struct {
	*sqlx.DB
}

func (bps *BaselinePopStore) Commit() error {
	return nil
}

func (bps *BaselinePopStore) Rollback() error {
	return nil
}

func (bps *BaselinePopStore) Transaction() (*pop.Tx, error) {
	t := &pop.Tx{
		ID: rand.Int(),
	}
	tx, err := bps.DB.Beginx()
	t.Tx = tx
	return t, fmt.Errorf("could not create new transaction %w", err)
}

func getPopConnectionDetails() pop.ConnectionDetails {
  return pop.ConnectionDetails {
    Dialect: "postgres",
    Database: "my_test_db",
    // ...
  }
}

func getDbUrl(popDetails pop.ConnectionDetails) string {
	return fmt.Sprintf("%s://%s:%s@%s:%s/%s?sslmode=%s",
		popDetails.Dialect, popDetails.User, popDetails.Password, popDetails.Host,
		popDetails.Port, popDetails.Database, popDetails.Options["sslmode"])
}

popDetails := getPopConnectionDetails()
realDbUrl := getDbUrl(popDetails)
txdb.Register("pgx", "postgres", realDbUrl)
popDetails.Driver = "pgx"
popDetails.Dialect = "postgres"
setupFunc := func(testName string, btest*httpbaselinetest.HttpBaselineTest) error {
  popDetails.Database = "txdb_" + httpbaselinetest.NormalizeTestName(name)
  popConn, err := pop.NewConnection(popDetails)
  if err != nil {
	return fmt.Errorf("Cannot create new POP connection: %s", err)
  }
  testDbUrl := getDbUrl(popDetails)
  db, err := sqlx.Connect(popDetails.Driver, testDbUrl)
  if err != nil {
	return err
  }
  popConn.Store = &BaselinePopStore{DB: db}
  // can now use popConn as usual
}

Caveats and Complications

Thinking of your HTTP service as a state machine is very powerful, but also may require re-thinking how you configure your service. Ideally you want a way to configure all of the initial state of your service. That includes things like ways to configure the current time, generate UUIDs from a specific random source, etc. If this is possible, then you can run your tests in parallel and know that they will produce deterministic results.

Unfortunately, it's very common for go libraries to keep package private global state. You may have to find creative ways to reset this state between tests. This also means you cannot run your tests in parallel.

Making your service deterministic is a challenge, but doing so can help fully understand all of the state your service depends on. If you can capture this, that makes it more likely you can reproduce bugs seen in production in a development environment.

Another consequence is that baselines need to be normalized. For example, the order of fields in a JSON response shouldn't matter. However, sometimes the order of objects in an array doesn't matter (it can be whatever order is returned by the db), but sometimes it does because the underlying request expects objects ordered by some criteria.

Additional features to post-process baselines may be needed.

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func NormalizeTestName

func NormalizeTestName(name string) string

Types

type BodyValidatorFunc

type BodyValidatorFunc func(body []byte) error

type HTTPBaselineTest

type HTTPBaselineTest struct {
	Setup    SetupFunc
	Teardown TeardownFunc
	Custom   interface{}

	Handler           http.Handler
	Method            string
	Path              string
	Host              string
	Body              interface{} // io.Reader or string
	Headers           map[string]string
	Cookies           []http.Cookie
	RequestValidator  BodyValidatorFunc
	ResponseValidator BodyValidatorFunc

	Db       *sqlx.DB
	Seed     string
	SeedFunc SeedFunc
	Tables   []string
}

type JSONTableData

type JSONTableData map[string]bool

type SeedFunc

type SeedFunc func(baselineTest *HTTPBaselineTest) error

type SetupFunc

type SetupFunc func(testName string, baselineTest *HTTPBaselineTest) error

type Suite

type Suite struct {
	// contains filtered or unexported fields
}

func NewDefaultSuite

func NewDefaultSuite(t *testing.T) *Suite

func (*Suite) Run

func (suite *Suite) Run(name string, btest HTTPBaselineTest)

type TeardownFunc

type TeardownFunc func(t *testing.T, baselineTest *HTTPBaselineTest) error

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL