Testing best practices
General guidelines
- Be explicit when verifying values
- Avoid
NotEqual
orNotNil
tests because they can lead to a false sense of security. Prefer testing for specific values withEqual
orNil
. - Before iterating through a range, make sure that the range is not empty first.
- When verifying an expected error, you must check the type of the error, and if necessary, the contents of the error message.
suite.Error(err)
orsuite.NotNil(err)
is not enough because they can result in false positives of false negatives. - Do not write a huge setup with subtests that reference variables several lines above the subtest. Create a separate setup for each test for readability.
- Subtests must be able to run in isolation. You can verify this with Goland, which makes it easy to run individual subtests.
- Avoid
mock.anything
. Part of mocking is to ensure that the object that is mocked has been called with the right arguments. If a handler is supposed to call a service object with a specific model ID, then if you usemock.anything
in the test and you introduce a bug in the handler where it passes in the wrong ID, the test will still pass. If you find it hard to avoid usingmock.anything
, it's most likely a sign the code under test is not designed properly. - Always validate
Params
in handlers usingsuite.NoError(params.Body.Validate(strfmt.Default))
- When using
testdatagen
to create data for test, passStub: true
into the assertions when the object being created doesn't need to be saved to the database. Examples of when you would want to use this are when the test only needs to check for validation errors or when returning a mocked response.
Examples of tests that will pass when they should fail
Using NotEqual
This test below is supposed to verify that if a service item has an existing RejectionReason
, and if the service item is approved, it should no longer have a RejectionReason
. The way the test is making that verification is by asserting that the updated service item's RejectionReason
is not equal to the one it had at the beginning of the test. This is not a valid test because if the code changed to set the RejectionReason
to any value that is not the original one, the test would pass, but we expect it to fail. Instead, we should assert that the RejectionReason
is nil
.
func (suite *HandlerSuite) TestUpdateMTOServiceItemStatusHandlerApproveSuccess() {
mtoServiceItem := suite.createServiceItem()
mtoServiceItem.Status = models.MTOServiceItemStatusApproved
reason := "should not update reason"
mtoServiceItem.RejectionReason = &reason
request := httptest.NewRequest("PATCH", "/service-items/{mtoServiceItemID}/status", nil)
params := mtoserviceitemop.UpdateMTOServiceItemStatusParams{
HTTPRequest: request,
MtoServiceItemID: mtoServiceItem.ID.String(),
Body: payloads.MTOServiceItem(&mtoServiceItem),
IfMatch: etag.GenerateEtag(mtoServiceItem.UpdatedAt),
}
// rest of handler boilerplate goes here
suite.NotEqual(mtoServiceItemPayload.RejectionReason, reason)
}
There is another issue with the test above. Can you spot it?
The RejectionReason
is not being saved to the database, and so it will always be nil
in the payload. If you comment out the code in the service that sets the RejectionReason
to nil
, the test will still pass, which is not good. Here is what the test should look like:
func (suite *HandlerSuite) TestUpdateMTOServiceItemStatusHandlerApproveSuccess() {
mtoServiceItem := suite.createServiceItem()
reason := "should not update reason"
mtoServiceItem.RejectionReason = &reason
suite.MustSave(&mtoServiceItem)
mtoServiceItem.Status = models.MTOServiceItemStatusApproved
request := httptest.NewRequest("PATCH", "/service-items/{mtoServiceItemID}/status", nil)
params := mtoserviceitemop.UpdateMTOServiceItemStatusParams{
HTTPRequest: request,
MtoServiceItemID: mtoServiceItem.ID.String(),
Body: payloads.MTOServiceItem(&mtoServiceItem),
IfMatch: etag.GenerateEtag(mtoServiceItem.UpdatedAt),
}
// rest of handler boilerplate goes here
suite.Nil(mtoServiceItemPayload.RejectionReason)
}
Not checking the length of an array before iterating through it
In this test below, if the UpdateMTOShipmentStatus
function is refactored and
stops returning service items, the test will still pass. If
the serviceItems
array is empty, the code inside the for
loop will
not be executed.
suite.T().Run("Create approved service items when the mtoShipment is approved", func(t *testing.T) {
shipmentForAutoApproveEtag := etag.GenerateEtag(shipmentForAutoApprove.UpdatedAt)
serviceItems := models.MTOServiceItems{}
_, err := updater.UpdateMTOShipmentStatus(shipmentForAutoApprove.ID, status, nil, shipmentForAutoApproveEtag)
suite.NoError(err)
err = suite.DB().Where("mto_shipment_id = ?", shipmentForAutoApprove.ID).All(&serviceItems)
suite.NoError(err)
// If we've gotten the shipment updated and fetched it without error then we can inspect the
// service items created as a side effect to see if they are approved.
for _, serviceItem := range serviceItems {
suite.Equal(models.MTOServiceItemStatusApproved, serviceItem.Status)
}
}
To make this test more robust, first make sure the serviceItems
array contains the expected amount of items:
suite.Equal(6, len(serviceItems))