From 9d80b8c83dd76737a6f96e5588c266e79d3fb495 Mon Sep 17 00:00:00 2001 From: Johnny Steenbergen Date: Tue, 23 Jan 2024 14:22:15 -0600 Subject: [PATCH] chore(allsrv): add db test suite By adding a DB test suite, we've effectively lowered the bar to entry for any additional DBs we wish to add. With this test suite, we can re-run it against a SQL DB, nosql db, etc. and expect it to satisfy the behavior the tests cover. Interestingly, this is not limited to dbs. There are a number of great examples of test suites in open source software. Here are a couple examples: * [influxdb](https://github.com/influxdata/influxdb/tree/v2.0.0/testing): things to note here are the abstraction around KV and the service behavior. However, reusing the pkg name, testing, is not something i'd advise doing. It will create confusion. * [vice](https://github.com/matryer/vice/blob/master/vicetest/test.go): this lib does a great job abstracting over the expected _behavior_ similar to influxdb. When you look through the test for each queue you should see the `vicetest` pkg's exported tests being called. The pkg name, vicetest, is very explicit, would highly recommend using a similar naming strategy. The key thing here is the language of the database interface. Just as the two examples above are abstracting over the behavior, we do the same here. Since the closest thing we have to a domain type is the `Foo` type, we utilize that as our domain language. Any db will need to be able take a domain Foo and persist it in using whatever implementation they desire. We aren't bleeding any details beyond the point of implementation. To illustrate this, I removed the GORM struct tags, as the domain type should not be limited by the db design. Now that we have a little test suite stood up, go on and add a new db implementation and make sure it passes the test suite. I will be adding a sql store, with sqlite, as a example to build from, however, feel free to explore this problem space however you'd like. Refs: [InfluxDB Testing](https://github.com/influxdata/influxdb/tree/v2.0.0/testing) Refs: [Vice Test Suite](https://github.com/matryer/vice/blob/master/vicetest/test.go) --- allsrv/db_inmem_test.go | 264 +------------------- allsrv/db_test.go | 530 ++++++++++++++++++++++++++++++++++++++++ allsrv/server.go | 10 +- 3 files changed, 537 insertions(+), 267 deletions(-) create mode 100644 allsrv/db_test.go diff --git a/allsrv/db_inmem_test.go b/allsrv/db_inmem_test.go index a2cfd22..e00ce64 100644 --- a/allsrv/db_inmem_test.go +++ b/allsrv/db_inmem_test.go @@ -1,273 +1,13 @@ package allsrv_test import ( - "context" - "errors" - "sync" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/jsteenb2/mess/allsrv" ) func TestInmemDB(t *testing.T) { - t.Run("create foo", func(t *testing.T) { - t.Run("with valid foo should pass", func(t *testing.T) { - db := new(allsrv.InmemDB) - - want := allsrv.Foo{ - ID: "1", - Name: "name", - Note: "note", - } - err := db.CreateFoo(context.TODO(), want) - require.NoError(t, err) - - got, err := db.ReadFoo(context.TODO(), "1") - require.NoError(t, err) - - assert.Equal(t, want, got) - }) - - t.Run("with concurrent valid foo creates should pass", func(t *testing.T) { - db := new(allsrv.InmemDB) - - newFoo := func(id string) allsrv.Foo { - return allsrv.Foo{ - ID: id, - Name: "name-" + id, - Note: "note-" + id, - } - } - - var wg sync.WaitGroup - for _, f := range []allsrv.Foo{newFoo("1"), newFoo("2"), newFoo("3"), newFoo("4"), newFoo("5")} { - wg.Add(1) - go func(f allsrv.Foo) { - defer wg.Done() - require.NoError(t, db.CreateFoo(context.TODO(), f)) - }(f) - } - wg.Wait() - }) - - t.Run("with foo containing name that already exists should fail", func(t *testing.T) { - db := new(allsrv.InmemDB) - - want := allsrv.Foo{ - ID: "1", - Name: "collision", - Note: "note", - } - err := db.CreateFoo(context.TODO(), want) - require.NoError(t, err) - - err = db.CreateFoo(context.TODO(), want) - - // this is pretty gross, we're matching against a raw error/text value - // any change in the error message means we have to update tests too - wantErr := errors.New("foo collision exists") - assert.Equal(t, wantErr.Error(), err.Error()) - }) - }) - - t.Run("read foo", func(t *testing.T) { - t.Run("with id for existing foo should pass", func(t *testing.T) { - db := new(allsrv.InmemDB) - - want := allsrv.Foo{ - ID: "1", - Name: "name", - Note: "note", - } - err := db.CreateFoo(context.TODO(), want) - require.NoError(t, err) - - got, err := db.ReadFoo(context.TODO(), "1") - require.NoError(t, err) - - assert.Equal(t, want, got) - }) - - t.Run("with concurrent valid foo update the reading should pass", func(t *testing.T) { - db := new(allsrv.InmemDB) - require.NoError(t, db.CreateFoo(context.TODO(), allsrv.Foo{ - ID: "1", - Name: "one", - Note: "note", - })) - - newFoo := func(note string) allsrv.Foo { - return allsrv.Foo{ - ID: "1", - Name: "one", - Note: note, - } - } - - var wg sync.WaitGroup - for _, f := range []allsrv.Foo{newFoo("a"), newFoo("b"), newFoo("c"), newFoo("d"), newFoo("e")} { - wg.Add(1) - go func(f allsrv.Foo) { - defer wg.Done() - require.NoError(t, db.UpdateFoo(context.TODO(), f)) - }(f) - } - - got, err := db.ReadFoo(context.TODO(), "1") - require.NoError(t, err) - - assert.Contains(t, []string{"note", "a", "b", "c", "d", "e"}, got.Note) - wg.Wait() - }) - - t.Run("with id for non-existent foo should fail", func(t *testing.T) { - db := new(allsrv.InmemDB) - - _, err := db.ReadFoo(context.TODO(), "1") - - // this is pretty gross, we're matching against a raw error/text value - // any change in the error message means we have to update tests too - want := errors.New("foo not found for id: 1") - assert.Equal(t, want.Error(), err.Error()) - }) - }) - - t.Run("update foo", func(t *testing.T) { - t.Run("with valid update for existing foo should pass", func(t *testing.T) { - db := new(allsrv.InmemDB) - - want := allsrv.Foo{ - ID: "1", - Name: "name", - Note: "note", - } - err := db.CreateFoo(context.TODO(), want) - require.NoError(t, err) - - want.Note = "some other note" - err = db.UpdateFoo(context.TODO(), want) - require.NoError(t, err) - - got, err := db.ReadFoo(context.TODO(), "1") - require.NoError(t, err) - - assert.Equal(t, want, got) - }) - - t.Run("with concurrent valid foo updates should pass", func(t *testing.T) { - db := new(allsrv.InmemDB) - require.NoError(t, db.CreateFoo(context.TODO(), allsrv.Foo{ - ID: "1", - Name: "one", - Note: "note", - })) - - newFoo := func(note string) allsrv.Foo { - return allsrv.Foo{ - ID: "1", - Name: "one", - Note: note, - } - } - - var wg sync.WaitGroup - for _, f := range []allsrv.Foo{newFoo("a"), newFoo("b"), newFoo("c"), newFoo("d"), newFoo("e")} { - wg.Add(1) - go func(f allsrv.Foo) { - defer wg.Done() - require.NoError(t, db.UpdateFoo(context.TODO(), f)) - }(f) - } - - got, err := db.ReadFoo(context.TODO(), "1") - require.NoError(t, err) - wg.Wait() - - assert.Contains(t, []string{"note", "a", "b", "c", "d", "e"}, got.Note) - }) - - t.Run("with update for non-existent foo should fail", func(t *testing.T) { - db := new(allsrv.InmemDB) - - err := db.UpdateFoo(context.TODO(), allsrv.Foo{ - ID: "1", - Name: "name", - Note: "note", - }) - - // this is pretty gross, we're matching against a raw error/text value - // any change in the error message means we have to update tests too - want := errors.New("foo not found for id: 1") - assert.Equal(t, want.Error(), err.Error()) - }) - }) - - t.Run("delete foo", func(t *testing.T) { - t.Run("with id for existing foo should pass", func(t *testing.T) { - db := new(allsrv.InmemDB) - - err := db.CreateFoo(context.TODO(), allsrv.Foo{ - ID: "1", - Name: "name", - Note: "note", - }) - require.NoError(t, err) - - err = db.DelFoo(context.TODO(), "1") - require.NoError(t, err) - - _, err = db.ReadFoo(context.TODO(), "1") - - // this is pretty gross, we're matching against a raw error/text value - // any change in the error message means we have to update tests too - want := errors.New("foo not found for id: 1") - assert.Equal(t, want.Error(), err.Error()) - }) - - t.Run("with concurrent valid foo creates should pass", func(t *testing.T) { - db := new(allsrv.InmemDB) - - newFoo := func(id string) allsrv.Foo { - return allsrv.Foo{ - ID: id, - Name: "name-" + id, - Note: "note-" + id, - } - } - - for _, f := range []allsrv.Foo{newFoo("1"), newFoo("2"), newFoo("3"), newFoo("4"), newFoo("5")} { - require.NoError(t, db.CreateFoo(context.TODO(), f)) - } - - var wg sync.WaitGroup - for _, id := range []string{"1", "2", "3", "4", "5"} { - wg.Add(1) - go func(id string) { - defer wg.Done() - require.NoError(t, db.DelFoo(context.TODO(), id)) - }(id) - } - wg.Wait() - - for _, id := range []string{"1", "2", "3", "4", "5"} { - err := db.DelFoo(context.TODO(), id) - wantErr := errors.New("foo not found for id: " + id) - require.Error(t, wantErr, err) - } - }) - - t.Run("with id for non-existent foo should fail", func(t *testing.T) { - db := new(allsrv.InmemDB) - - err := db.DelFoo(context.TODO(), "1") - - // this is pretty gross, we're matching against a raw error/text value - // any change in the error message means we have to update tests too - want := errors.New("foo not found for id: 1") - assert.Equal(t, want.Error(), err.Error()) - }) + testDB(t, func(t *testing.T) allsrv.DB { + return new(allsrv.InmemDB) }) } diff --git a/allsrv/db_test.go b/allsrv/db_test.go new file mode 100644 index 0000000..c86e9a1 --- /dev/null +++ b/allsrv/db_test.go @@ -0,0 +1,530 @@ +package allsrv_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/jsteenb2/mess/allsrv" +) + +type dbInitFn func(t *testing.T) allsrv.DB + +func testDB(t *testing.T, initFn dbInitFn) { + t.Helper() + + tests := []struct { + name string + fn func(t *testing.T, initFn dbInitFn) + }{ + { + name: "CreateFoo", + fn: testDBCreateFoo, + }, + { + name: "ReadFoo", + fn: testDBReadFoo, + }, + { + name: "UpdateFoo", + fn: testDBUpdateFoo, + }, + { + name: "DelFoo", + fn: testDBDeleteFoo, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.fn(t, initFn) + }) + } +} + +func testDBCreateFoo(t *testing.T, initFn dbInitFn) { + t.Helper() + + type ( + inputs struct { + foo allsrv.Foo + } + + wantFn func(t *testing.T, db allsrv.DB, insertErr error) + ) + + start := time.Time{}.Add(time.Hour).UTC() + + tests := []struct { + name string + prepare func(t *testing.T, db allsrv.DB) + inputs inputs + want wantFn + }{ + { + name: "with valid foo should pass", + inputs: inputs{ + foo: allsrv.Foo{ + ID: "1", + Name: "name", + Note: "note", + CreatedAt: start, + UpdatedAt: start, + }, + }, + want: func(t *testing.T, db allsrv.DB, insertErr error) { + require.NoError(t, insertErr) + + got, err := db.ReadFoo(context.TODO(), "1") + require.NoError(t, err) + + want := allsrv.Foo{ + ID: "1", + Name: "name", + Note: "note", + CreatedAt: start, + UpdatedAt: start, + } + assert.Equal(t, want, got) + }, + }, + { + name: "with concurrent valid foo creates should pass", + prepare: func(t *testing.T, db allsrv.DB) { + newFoo := func(id string) allsrv.Foo { + return allsrv.Foo{ + ID: id, + Name: "name-" + id, + Note: "note-" + id, + CreatedAt: start.Add(time.Minute), + UpdatedAt: start.Add(time.Minute), + } + } + + // execute while rest of test completes + for _, f := range []allsrv.Foo{newFoo("1"), newFoo("2"), newFoo("3"), newFoo("4"), newFoo("5")} { + go func(f allsrv.Foo) { + require.NoError(t, db.CreateFoo(context.TODO(), f)) + }(f) + } + }, + inputs: inputs{ + foo: allsrv.Foo{ + ID: "9000", + Name: "passing", + Note: "note", + CreatedAt: start, + UpdatedAt: start, + }, + }, + want: func(t *testing.T, db allsrv.DB, insertErr error) { + require.NoError(t, insertErr) + + got, err := db.ReadFoo(context.TODO(), "9000") + require.NoError(t, err) + + want := allsrv.Foo{ + ID: "9000", + Name: "passing", + Note: "note", + CreatedAt: start, + UpdatedAt: start, + } + assert.Equal(t, want, got) + }, + }, + { + name: "with foo containing name that already exists should fail", + prepare: createFoos(allsrv.Foo{ID: "1", Name: "collision"}), + inputs: inputs{ + foo: allsrv.Foo{ + ID: "2", + Name: "collision", + Note: "some note", + CreatedAt: start, + UpdatedAt: start, + }, + }, + want: func(t *testing.T, db allsrv.DB, insertErr error) { + require.Error(t, insertErr) + }, + }, + // { + // name: "with foo containing ID that already exists should fail", + // prepare: prepDBFoos(allsrv.Foo{ID: "1", Name: "name-1"}), + // inputs: inputs{ + // foo: allsrv.Foo{ + // ID: "1", + // Name: "name-2", + // Note: "some note", + // CreatedAt: start, + // UpdatedAt: start, + // }, + // }, + // want: func(t *testing.T, db allsrv.DB, insertErr error) { + // require.Error(t, insertErr) + // }, + // }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // setup + db := initFn(t) + if tt.prepare != nil { + tt.prepare(t, db) + } + + // action + insertErr := db.CreateFoo(context.TODO(), tt.inputs.foo) + + // assert + tt.want(t, db, insertErr) + }) + } +} + +func testDBReadFoo(t *testing.T, initFn dbInitFn) { + t.Helper() + + type ( + inputs struct { + id string + } + + wantFn func(t *testing.T, got allsrv.Foo, readErr error) + ) + + start := time.Time{}.Add(time.Hour).UTC() + + tests := []struct { + name string + prepare func(t *testing.T, db allsrv.DB) + inputs inputs + want wantFn + }{ + { + name: "with id for existing foo should pass", + prepare: createFoos(allsrv.Foo{ + ID: "1", + Name: "name-1", + Note: "note-1", + CreatedAt: start, + UpdatedAt: start.Add(time.Hour), + }), + inputs: inputs{ + id: "1", + }, + want: func(t *testing.T, got allsrv.Foo, readErr error) { + require.NoError(t, readErr) + + want := allsrv.Foo{ + ID: "1", + Name: "name-1", + Note: "note-1", + CreatedAt: start, + UpdatedAt: start.Add(time.Hour), + } + assert.Equal(t, want, got) + }, + }, + { + name: "with concurrent valid foo update the reading should pass", + prepare: func(t *testing.T, db allsrv.DB) { + err := db.CreateFoo(context.TODO(), allsrv.Foo{ + ID: "1", + Name: "one", + Note: "note", + CreatedAt: start, + UpdatedAt: start, + }) + require.NoError(t, err) + + newFoo := func(note string) allsrv.Foo { + return allsrv.Foo{ + ID: "1", + Name: "one", + Note: note, + CreatedAt: start, + UpdatedAt: start.Add(time.Hour), + } + } + + // execute these while test is executing read + for _, f := range []allsrv.Foo{newFoo("a"), newFoo("b"), newFoo("c"), newFoo("d"), newFoo("e")} { + go func(f allsrv.Foo) { + require.NoError(t, db.UpdateFoo(context.TODO(), f)) + }(f) + } + }, + inputs: inputs{ + id: "1", + }, + want: func(t *testing.T, got allsrv.Foo, readErr error) { + require.NoError(t, readErr) + assert.Contains(t, []string{"note", "a", "b", "c", "d", "e"}, got.Note) + }, + }, + { + name: "with id for non-existent foo should fail", + inputs: inputs{ + id: "1", + }, + want: func(t *testing.T, _ allsrv.Foo, readErr error) { + // this is pretty gross, we're matching against a raw error/text value + // any change in the error message means we have to update tests too + want := errors.New("foo not found for id: 1") + assert.Equal(t, want.Error(), readErr.Error()) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // setup + db := initFn(t) + if tt.prepare != nil { + tt.prepare(t, db) + } + + // action + got, err := db.ReadFoo(context.TODO(), tt.inputs.id) + + // assert + tt.want(t, got, err) + }) + } +} + +func testDBUpdateFoo(t *testing.T, initFn dbInitFn) { + type ( + inputs struct { + foo allsrv.Foo + } + + wantFn func(t *testing.T, db allsrv.DB, updateErr error) + ) + + start := time.Time{}.Add(time.Hour).UTC() + + tests := []struct { + name string + prepare func(t *testing.T, db allsrv.DB) + inputs inputs + want wantFn + }{ + { + name: "with valid update for existing foo should pass", + prepare: createFoos(allsrv.Foo{ + ID: "1", + Name: "name", + Note: "note", + CreatedAt: start, + UpdatedAt: start, + }), + inputs: inputs{ + foo: allsrv.Foo{ + ID: "1", + Name: "name", + Note: "some other note", + CreatedAt: start, + UpdatedAt: start.Add(time.Hour), + }, + }, + want: func(t *testing.T, db allsrv.DB, updateErr error) { + require.NoError(t, updateErr) + + got, err := db.ReadFoo(context.TODO(), "1") + require.NoError(t, err) + + want := allsrv.Foo{ + ID: "1", + Name: "name", + Note: "some other note", + CreatedAt: start, + UpdatedAt: start.Add(time.Hour), + } + assert.Equal(t, want, got) + }, + }, + { + name: "with concurrent valid foo updates should pass", + prepare: func(t *testing.T, db allsrv.DB) { + require.NoError(t, db.CreateFoo(context.TODO(), allsrv.Foo{ + ID: "1", + Name: "one", + Note: "note", + CreatedAt: start, + UpdatedAt: start, + })) + + newFoo := func(note string) allsrv.Foo { + return allsrv.Foo{ + ID: "1", + Name: "one", + Note: note, + UpdatedAt: start.Add(time.Hour), + } + } + + for _, f := range []allsrv.Foo{newFoo("a"), newFoo("b"), newFoo("c"), newFoo("d"), newFoo("e")} { + go func(f allsrv.Foo) { + require.NoError(t, db.UpdateFoo(context.TODO(), f)) + }(f) + } + }, + inputs: inputs{ + foo: allsrv.Foo{ + ID: "1", + Name: "one", + Note: "final", + UpdatedAt: start.Add(24 * time.Hour), + }, + }, + want: func(t *testing.T, db allsrv.DB, updateErr error) { + require.NoError(t, updateErr) + + got, err := db.ReadFoo(context.TODO(), "1") + require.NoError(t, err) + + assert.Contains(t, []string{"final", "note", "a", "b", "c", "d", "e"}, got.Note) + }, + }, + { + name: "with update for non-existent foo should fail", + inputs: inputs{ + foo: allsrv.Foo{ + ID: "1", + Name: "name", + Note: "note", + CreatedAt: start, + UpdatedAt: start.Add(time.Hour), + }, + }, + want: func(t *testing.T, db allsrv.DB, updateErr error) { + require.Error(t, updateErr) + + // this is pretty gross, we're matching against a raw error/text value + // any change in the error message means we have to update tests too + want := errors.New("foo not found for id: 1") + assert.Equal(t, want.Error(), updateErr.Error()) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // setup + db := initFn(t) + if tt.prepare != nil { + tt.prepare(t, db) + } + + // action + err := db.UpdateFoo(context.TODO(), tt.inputs.foo) + + // assert + tt.want(t, db, err) + }) + } +} + +func testDBDeleteFoo(t *testing.T, initFn dbInitFn) { + t.Helper() + + type ( + inputs struct { + id string + } + + wantFn func(t *testing.T, db allsrv.DB, delErr error) + ) + + start := time.Time{}.Add(time.Hour).UTC() + + tests := []struct { + name string + prepare func(t *testing.T, db allsrv.DB) + inputs inputs + want wantFn + }{ + { + name: "with id for existing foo should pass", + prepare: createFoos(allsrv.Foo{ID: "1", Name: "blue"}), + inputs: inputs{ + id: "1", + }, + want: func(t *testing.T, db allsrv.DB, delErr error) { + require.NoError(t, delErr) + + _, err := db.ReadFoo(context.TODO(), "1") + + // this is pretty gross, we're matching against a raw error/text value + // any change in the error message means we have to update tests too + want := errors.New("foo not found for id: 1") + assert.Equal(t, want.Error(), err.Error()) + }, + }, + { + name: "with concurrent valid foo creates should pass", + prepare: func(t *testing.T, db allsrv.DB) { + newFoo := func(id string) allsrv.Foo { + return allsrv.Foo{ + ID: id, + Name: "name-" + id, + Note: "note-" + id, + CreatedAt: start, + UpdatedAt: start, + } + } + + for _, f := range []allsrv.Foo{newFoo("1"), newFoo("2"), newFoo("3"), newFoo("4"), newFoo("5")} { + require.NoError(t, db.CreateFoo(context.TODO(), f)) + } + + // leave the foo "1" del for the input + for _, id := range []string{"2", "3", "4", "5"} { + go func(id string) { + require.NoError(t, db.DelFoo(context.TODO(), id)) + }(id) + } + }, + inputs: inputs{id: "1"}, + want: func(t *testing.T, db allsrv.DB, delErr error) { + require.NoError(t, delErr) + }, + }, + { + name: "with id for non-existent foo should fail", + inputs: inputs{id: "1"}, + want: func(t *testing.T, db allsrv.DB, delErr error) { + require.Error(t, delErr) + + // this is pretty gross, we're matching against a raw error/text value + // any change in the error message means we have to update tests too + want := errors.New("foo not found for id: 1") + assert.Equal(t, want.Error(), delErr.Error()) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // setup + db := initFn(t) + if tt.prepare != nil { + tt.prepare(t, db) + } + + // action + delErr := db.DelFoo(context.TODO(), tt.inputs.id) + + // assert + tt.want(t, db, delErr) + }) + } +} diff --git a/allsrv/server.go b/allsrv/server.go index 15580ca..a73bbad 100644 --- a/allsrv/server.go +++ b/allsrv/server.go @@ -139,11 +139,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { type Foo struct { // 6) - ID string `json:"id" gorm:"id"` - Name string `json:"name" gorm:"name"` - Note string `json:"note" gorm:"note"` - CreatedAt time.Time `json:"-" gorm:"created_at"` - UpdatedAt time.Time `json:"-" gorm:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + Note string `json:"note"` + CreatedAt time.Time `json:"-"` + UpdatedAt time.Time `json:"-"` } func (s *Server) createFoo(w http.ResponseWriter, r *http.Request) {