Testing external API calls can sometimes be a challenge. Nevertheless, it is important to make sure your logic around external dependencies is behaving as expected.
In this post, I’ll use my prejoin attendance list demo to present a method of testing calls to Daily’s REST API in Go, which is a popular language for backend services.
This post assumes the reader already has some prior experience with Go, so I won’t get too into the basics here. But I will provide links to help you brush up on any relevant concepts if needed as you go.
An overview of the demo
If you haven’t already read my tutorial on implementing a prejoin attendance list with Daily, let’s cover the basics of the application now. If you are already familiar with the demo, feel free to skip this section.
The prejoin attendance list demo lets a user create a new Daily video call room and then join its pre-call lobby. When this lobby is joined, a list of participants who are already in the call is shown on the right-hand side. This enables the user to get a sneak peek of who’s already there before they hop into the call.
To run the demo locally, refer to the README in the GitHub repository.
For the purposes of this post, we mostly care about the server-side components, which are written as Netlify stateless functions in Go. I have a Netlify endpoint for creating a room and another for retrieving video call presence. I have implemented some basic tests for these. These tests are what I’ll focus on in this post.
With that overview, let’s move into the testing method.
How I test external API calls in Go
My objective when testing external API calls is to test my handling of the returned data, not to test the external API itself. I want to make sure my own code handles data returned by Daily appropriately.
My preferred method of testing external calls in Go these days is using the httptest
package, which is part of Go’s standard library. There are other ways, like wrapping external request logic with an interface and then mocking said interface, but I find spinning up a little test server for every test case to be a more intuitive approach most of the time.
This is the method I used for testing my calls to Daily’s REST API in my prejoin attendance list demo. Both the room creation and presence retrieval tests follow the same approach. In this post, I’ll use the presence retrieval test as an example.
Table-driven tests
I opt for using table-driven tests when testing Go. A table-driven test is essentially a test that defines a number of inputs and expected outputs within the test itself, and reuses core logic for running the test using that data. You’ll often see a new struct being defined in the test function. This struct dictates the structure of test inputs and outputs. You'd then have a variable specifying test cases according to this structure.
My presence test looks like this:
func TestGetPresence(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
retCode int
retBody string
wantErr error
wantParticipants []Participant
}{
// Multiple test cases here
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Shared test logic here
})
}
}
This test will focus specifically on testing my endpoint’s getPresence()
function, which is where the call out to Daily’s REST API takes place.
The test case struct above defines a few key pieces of information:
name
is the name of the test case. This should be something you can easily understand in the logs.retCode
is the return code I’ll be simulating from Daily.retBody
is the return body I’ll be simulating from Daily.wantErr
is the error I expect my logic to return after processing Daily’s simulated return data.wantParticipants
is the slice of participants I’ll expect my logic to return after processing Daily’s return data.
After defining the test table and test cases, I have the shared logic that each test case will run through.
- I iterate over every test case and shadow the
tc
variable.t.Run()
runs the function literal within it in a separate goroutine. In Go, function literals retain references to variables in the outer scope. This means thetc
variable set as part of thefor
loop declaration can change for a running test case as the loop moves onto its next iteration. By shadowingtc
, I ensure that the test case data for each iteration is what I expect. - I then call
t.Run()
and pass it the core logic of the test, which we’ll go through next.
Defining the core logic of the test
Let’s start with covering the shared components of the test: the things that every test case will run through. This is where the test server will be spun up and destroyed for each test case.
func TestGetPresence(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
retCode int
retBody string
wantErr error
wantParticipants []Participant
}{
// Multiple test cases here
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tc.retCode)
_, err := w.Write([]byte(tc.retBody))
require.NoError(t, err)
}))
defer testServer.Close()
gotParticipants, gotErr := getPresence("name", "key", testServer.URL)
require.ErrorIs(t, gotErr, tc.wantErr)
if tc.wantErr == nil {
require.EqualValues(t, tc.wantParticipants, gotParticipants)
}
})
}
}
There are three main things happening above:
- I’m configuring the simulation of Daily’s REST API response.
- I’m invoking my function to be tested.
- I’m checking that the return values of that function match what I expect.
Simulating Daily’s REST API response
The first step above is the main one: Creating a test server with httptest.NewServer()
. The constructor here takes an instance of http.Handler
, which is an interface defining a single function: ServeHTTP(ResponseWriter, *Request)
.
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tc.retCode)
_, err := w.Write([]byte(tc.retBody))
require.NoError(t, err)
}))
defer testServer.Close()
Inside my test HTTP handler above, I write the return header I want to simulate (in this case the return code defined in my test case), and the body I want my fake-Daily to return.
I then verify that there are no issues with writing the body with require.NoError()
from the testify
toolkit. This will ensure the test fails if something happens to go wrong at this point.
After creating testServer
, I add a defer
statement to close the server. This statement will run as the last call of the test case.
That concludes the primary setup: configuring my test server to return the Daily data that I want my tested function to handle.
Calling the getPresence()
function
With the setup all done, I can test my actual function. I do so by calling getPresence()
and assigning its return values to two got
variables:
gotParticipants, gotErr := getPresence("name", "key", testServer.URL)
The get
and want
variable prefix format makes it clear to anyone reading the test which pieces of data I got from the thing I’m testing and which pieces of data I wanted to get from the thing I’m testing, and then compare them.
The first two parameters passed to getPresence
above are just dummy values: they will not impact the behavior of my test server response. The last one, testServer.URL
is very important.
When calling getPresence()
from my live handler code, that last parameter is the URL of Daily’s REST API. But in this case, we want to re-route the request through my fake server: the test server I created above. This is why I pass in testServer.URL
instead.
This may be a good example of how you sometimes need to structure your non-test code to be testable.
Writing testable code
It would have been very easy to write getPresence()
to not take a URL parameter at all, and just retrieve my DAILY_API_URL
environment variable from within the function. It could even be tempting: Why clutter the function signature with another argument when the data is right there?! But that would have made the testing approach desired here impossible, since I’d have no way to inject my test server into the process.
Testing the return values
After calling getPresence()
and consuming the return values, the last thing left to do is confirming that the data returned is what I expected. I like using testify’s require
package for this:
require.ErrorIs(t, gotErr, tc.wantErr)
if tc.wantErr == nil {
require.EqualValues(t, tc.wantParticipants, gotParticipants)
}
First, I check if the error I got is what I expect. If this require.ErrorIs()
check fails, the test will immediately fail and not proceed to the next check.
If it succeeds, I check if my wanted error was actually nil
: i.e., no error at all. In that case, I use require.EqualValues()
to compare the values of the participants I said I wanted to the values of the participants I actually got. If these do not match, the test will also fail.
That’s it for the primary bulk of our test logic! All that’s left is taking a quick look at a couple of examples of test case data I’m feeding into this test.
Defining test case data
When writing test cases for external APIs, my first stop is always the documentation. In this case, this would be Daily’s /room/:name/presence
endpoint docs. I’m especially interested in examples of API responses, which you can see at the bottom of that page.
That example usually defines my first test case, which I will pop right into the test cases slice I defined up above:
func TestGetPresence(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
retCode int
retBody string
wantErr error
wantParticipants []Participant
}{
{
name: "one-participant",
retCode: 200,
// This response is copied directly from the presence endpoint docs example:
// https://docs.daily.co/reference/rest-api/rooms/get-room-presence#example-request
retBody: `
{
"total_count": 1,
"data": [
{
"room": "w2pp2cf4kltgFACPKXmX",
"id": "d61cd7b2-a273-42b4-89bd-be763fd562c1",
"userId": "pbZ+ismP7dk=",
"userName": "Moishe",
"joinTime": "2023-01-01T20:53:19.000Z",
"duration": 2312
}
]
}
`,
wantParticipants: []Participant{
{
ID: "d61cd7b2-a273-42b4-89bd-be763fd562c1",
Name: "Moishe",
},
},
},
}
The retCode
and retBody
values above are taken straight from Daily’s docs.
I then define my wantParticipants
value based on what I expect that data to turn into once my logic runs.
In this case, I expect getPresence()
(the function being tested) to return a participant slice which includes one element: a participant with the ID of "d61cd7b2-a273-42b4-89bd-be763fd562c1"
and the name of ”Moishe"
.
The next thing I usually go for is testing failure responses. My test case for an internal server error returned by Daily looks like this:
{
name: "failure",
retCode: 500,
wantErr: util.ErrFailedDailyAPICall,
},
Here, I have no body for my Daily test server to return, and only return a 500
status code. In this case, I expect getPresence()
to return no slice of participants (i.e., a nil
value) and a pre-defined util.ErrFailedDailyAPICall
error.
These are just two basic examples. You can then flesh out the test with more test cases, such as:
- Testing more participants returned from Daily
- Testing unexpected data that your function does not know how to parse
- Testing unexpectedly long response times
With the core logic of the test remaining the same, testing more variations and code paths within your function becomes a matter of simply introducing another test case to your “table”.
Conclusion
In this post, I covered one way of testing calls to Daily’s REST API in Go. If you have any questions about testing any other parts of our video APIs, whether in Go or another language, don’t hesitate to reach out to our support team. I’d be very curious to hear about other developers’ approaches to testing with Daily. If you’d like to share, head over to our Discord community.