Skip to content

Instantly share code, notes, and snippets.

@maiamcc
Created April 28, 2021 03:54
Show Gist options
  • Save maiamcc/d65b69418353c7482cfb9931e19cc7fc to your computer and use it in GitHub Desktop.
Save maiamcc/d65b69418353c7482cfb9931e19cc7fc to your computer and use it in GitHub Desktop.

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

A. store the data in memory -- works kinda like a real DB except lives in memory and not on disk
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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment