The post that prompted this:
hmm I guess I am trying to test the function more broadly. Using the database is part of testing the function or api endpoint in this case. I'm open to the idea of mocking. It's something I've never done so would be nice to learn a little about. But ideally I would like to use an actual database. Strategies for both mocking and a real database would be nice. I'm more at the stage of learning about how I could about testing these things. I essentially want to do, POST some data, I do some logic, make some db calls and I want to confirm that the state of things on the db is what I expect. Or I guess this is more accurate, I'm testing an API endpoint that happens to make some db calls. what is the difference between mock out the database and interactions with a real database?
the latter is slow and annoying and only gets slower and more annoying as your system gets more complex. Do you spin up, seed, and tear down a new database every time you want to run tests? do you have a long-running database that accumulates cruft from every successive test and risks getting into a bad state because of weird queries? there are certainly less bad ways to run tests against a real database (i don't have much experience here but i'd lean towards running the database in a container), but I would almost always rather mock out the DB.
the disadvantage to mocking the DB is that you need to tell it how to respond to every situation, and if you implement behavior in your mock that differs from behavior in your real DB, your tests will get wonky. BUT you save lots of setup/teardown time, you avoid the "my DB got in a weird state" problem, and your tests are probably easier for others to run if there's no weird db setup step. (also, you don't necessarily need to set up your mock DB to the level of detail of "insert row A; then insert row A again; it should throw a DuplicateKey error" -- you could just say "next time you try to insert something into the mock DB, throw a DuplicateKey error")
in practice you'll probably want to do a combination of the two approaches: a large number of unit tests using a fake DB, and a much smaller set of integration tests/end-to-end tests/whatever you want to call it that use the real DB. this latter group of tests will presumably be run much less frequently (so it can stand to be slower and clunkier) but will hopefully still help you catch anything that's terribly wrong, or find weirdness on the boundary between your code and the real database that your unit tests with the mock DB missed
hm so in this case, i would probably use a mock DB that maybe just writes the data to memory, or maybe tracks how many times it calls each function and then your test can check whether InsertFoo got called with the correct parameters
like how this works in practice is, say you've got a database object
type sqlDB struct {
db *sql.DB
}
it contains a database connection, or a reference to your ORM, or whatever. It knows how to do some specific stuff, like idk, insert a User, get a User, etc.
func (sdb *sqlDB) GetUser(id int) (User, error) {
result, err := sdb.DoQuery("SELECT * FROM users WHERE...")
...
}
and you can imaging similar Get
and Insert
methods
to mock out this DB, we make an interface that has all of these methods:
type Datastore interface {
GetUser(id int) (User, error)
InsertUser(u User) error
GetRecord...
...
}
and anywhere in your code that needs the database should take something of type Datastore -- so in the real app you can pass in a sqlDB, and in tests you can pass in a mockDB
when it comes to actually implementing the mock DB, broadly speaking the options are to
type mockDB struct {
users map[int]User
}
func (mdb *mockDB) GetUser(id int) (User, error) {
if _, ok := mdb.users[u.ID]; !ok {
return ...NotFoundError...
}
return mdb.users[id], nil
}
func (mdb *mockDB) InsertUser(u User) error {
if _, ok := mdb.users[u.ID]; ok {
return ...DuplicateKeyViolationError...
}
mdb.users[u.ID] = u
return nil
}
you can either implement the logic for when to throw what type of error in the mock functions themselves, or do the thing i alluded to above, which is set a flag for "the next time I try to do a thing, throw this error", which might look like:
type mockDB struct {
users map[int]User
nextInsertUserError error
}
func (mdb *mockDB) GetUser(id int) (User, error) {
if mdb.nextInsertUserError != nil {
err := mdb.nextInsertUserError
mdb.nextInsertUserError = nil // clear the error so the next call is okay
return User{}, err
}
return mdb.users[id], nil
}
the other approach is
B. implement a mock DB that just records information about each call it gets, and spits out the information you tell it to
type mockDB struct {
insertUserCallCount int
lastInsertedUser User // if you want to track multiple calls, this can be a []User i.e. a list of previous calls
nextInsertUserError error
userToGet User
nextGetUserError error
}
func (mdb *mockDB) GetUser(id int) (User, error) {
if mdb.nextGetUserError != nil {
err := mdb.nextGetUserError
mdb.nextGetUserError = nil // clear the error so the next call is okay
return User{}, err
}
user := mdb.userToGet
mdb.userToGet = User{}
return user, nil
}
func (mdb *mockDB) InsertUser(u User) (error) {
mdb.insertUserCallCount++ // so your tests can assert how many times this func was called
mdb.lastInsertedUser = u // so your tests can assert that you inserted the right user
if mdb.nextInsertUserError != nil {
err := mdb.nextInsertUserError
mdb.nextInsertUserError = nil // clear the error so the next call is okay
return err
}
return nil
}
...that was a very longwinded explanation, apologies, I got into the sleepy almost-midnight groove and just kept typing 😅 hopefully that was helpful and not TMI, but if it was you can feel free to ignore. happy to answer more questions or kick around more ideas!