Skip to content

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

License

Notifications You must be signed in to change notification settings

trussworks/httpbaselinetest

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

27 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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.

About

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

Resources

License

Code of conduct

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published