How to write stable API tests

 

Based on daily test run statistics, we’ve found that most of failures happen because of READ and WRITE databases synchronization,

when test tries to interact (read, update, delete) with newly created (updated, deleted) entity before READ database updated and as a result test fails with NotFound status code from the API.

To avoid writing flaky API tests there’re are some recommendations below:

Creating entity

  • create request model

  • send create request

  • assert response

  • assert entity is accessible via read API

Flaky test example

Firstly, let’s have a look at the flaky test checking creation of the new user in the application:

Feature class

Context class

Feature class

Context class

[Scenario(DisplayName = "Create new user")] public void Create_new_user() { Runner.WithContext<UserContext>().RunScenario( _ => _.Given_user_model(), _ => _.When_send_create_user_request(), _ => _.Then_response_code_is(HttpStatusCode.Created), _ => _.When_send_get_user_request(), _ => _.Then_response_code_is(HttpStatusCode.OK) ); }

 

public void Given_user_model() { _userModel = new UserModel { Email = $"{Fixture.Create<string>()}@gmail.com", FirstName = Fixture.Create<string>(), LastName = Fixture.Create<string>(), ProviderUserId = Fixture.Create<string>(), UserName = Fixture.Create<string>() }; } public void When_send_create_user_request() { _apiResponse = _apiClient.CreateUser(_userModel.AsJson()); _userId = _apiResponse.GetGlobalId(); } public void Then_response_code_is(HttpStatusCode expectedCode) { AssertResponseCodeIs(_apiResponse, expectedCode); } public void When_send_get_user_request() { _apiResponse = _apiClient.GetAUser(_userId); } public void Then_response_code_is(HttpStatusCode expectedCode) { AssertResponseCodeIs(_apiResponse, expectedCode); }

The issue is in the lines 8 and 9 in the test scenario

_ => _.When_send_get_user_request(), _ => _.Then_response_code_is(HttpStatusCode.OK)

Sooner or later this test will fail with API response status code: 404 Not Found

This happens because by the moment we send GET request newly created user is not in the READ database yet.

Waiting for the READ database to be updated

The solution is to wait util READ database updated with new user before sending the GET request and checking the status code

We have at least two implementation of “waiting“ mechanism:

  1. Wait of some fixed amount of time:

  • Task.Delay(timeSpan).Wait();

  • Thread.Sleep(timeSpan);

The issue with this approach is that we don’t know exactly how much time needed to wait in every particular test, considering waiting time depends on the environment, network loading etc.

Also we can’t set, for example,10 seconds timeout in one place, as a global variable for every test, because it will significantly slow down the whole test suite execution time,

that’s why we will use another way of waiting mechanism implementation - polling mechanism

 

2. Waiting with polling interval

In this case we’ll send GET request in cycle with a response checking after each iteration

for (var i = 0; i < iterationsCount; i++) { actualResponse = request.Invoke(); if (condition.Invoke(actualResponse)) { break; } Task.Delay(frequency); }

 

Here we have waiting with a fixed amount of time: Task.Delay(frequency); but in this case waiting time is configurable, much more smaller and give tests more flexibility

The logic in the code snippet above was wrapped into ExecuteUntilResponseIs method in the ApiClientUtils class

Now let’s have a look how test for user creation should be implemented to make it stable and independent from database synchronization

We need to replace two lines:

_ => _.When_send_get_user_request(), _ => _.Then_response_code_is(HttpStatusCode.OK)

with:

_ => _.And_get_request_returns(HttpStatusCode.OK)

 

Feature class

Context class

Feature class

Context class

[Scenario(DisplayName = "Create new user")] public void Create_new_user() { Runner.WithContext<UserContext>().RunScenario( _ => _.Given_user_model(), _ => _.When_send_create_user_request(), _ => _.Then_response_code_is(HttpStatusCode.Created), _ => _.And_get_a_user_request_returns(HttpStatusCode.OK) ); }

 

public void And_get_a_user_request_returns(HttpStatusCode expectedCode) { var apiResponse = ApiClientUtils.ExecuteUntilResponseIs( () => _apiClient.GetAUser(_userId), r => r.StatusCode == expectedCode, frequency: 2, timeoutSec: 10); AssertResponseCodeIs(apiResponse, expectedCode, $"User with globalId {_userId} wasn't created!"); }

In this case we’ll send request to find user by global Id every 2 seconds , with a 10 seconds timeout

and after every request execution we check the response status code, if matches - exit the cycle and return API response

And finally after small refactoring moving lines 3-5 into separate method we get:

Feature class

Context class

Feature class

Context class

[Scenario(DisplayName="Createnewuser")] publicvoidCreate_new_user() { Runner.WithContext<UserContext>().RunScenario( _=>_.Given_user_model(), _=>_.When_send_create_user_request(), _=>_.Then_response_code_is(HttpStatusCode.Created), _=>_.And_get_a_user_request_returns(HttpStatusCode.OK) ); }

 

public void And_get_a_user_request_returns(HttpStatusCode expectedCode) { AssertRequestReturnsStatusCode( () => _apiClient.GetAUser(_userId), expectedCode, $"User with globalId {_userId} wasn't created!"); }

AssertRequestReturnsStatusCode method comes from BaseContext class.

Updating entity

  • create request model

  • send create request

  • assert entity is accessible via read API

  • send update request

  • assert response status code

  • send get request

  • assert entity was updated

Flaky test example

Let’s have a look at flaky test:

Feature class

Context class

Feature class

Context class

[Scenario(DisplayName = "Update user")] public void Update_user() { const string name = "new name"; Runner.WithContext<UserContext>().RunScenario( _ => _.Given_user_exists(), _ => _.When_user_name_updated(name), _ => _.Then_response_code_is(HttpStatusCode.NoContent), _ => _.When_send_get_user_request(), _ => _.Then_user_name_updated(name) ); }

 

public void Given_user_exists() { Given_user_model(); When_send_create_user_request(); Then_response_code_is(HttpStatusCode.Created); And_get_a_user_request_returns(HttpStatusCode.OK); } public void When_user_name_updated(string name) { _userModel.UserName = name; _apiResponse = _apiClient.UpdateUser(_userId, _userModel.AsJson()); } public void Then_user_name_updated(string expectedName) { var actualName = _apiResponse.AsJson<UserGetModel>().UserName; AssertionUtils.AssertEqual(expectedName, actualName, $"User name wasn't updated!\n" + $"Api response: {_apiResponse.Content}"); }

Here we have issue in these two scenario steps:

_ => _.When_send_get_user_request(), _ => _.Then_user_name_updated(name)

Considering GET request may be executed before READ database updated, variable “actualName” may have the old value and test will fail.

We need to modify Then_user_name_updated step and first and straightforward solution looks like this:

Code

Comments

Code

Comments

public void Then_user_name_updated(string expectedName) { var apiResponse = ApiClientUtils.ExecuteUntilResponseIs( () => _apiClient.GetAUser(_userId), r => r.AsJson<UserGetModel>().UserName.Equals(expectedName)); var actualName = apiResponse.AsJson<UserGetModel>().UserName; AssertionUtils.AssertEqual(expectedName, actualName, $"User name wasn't updated!\n" + $"Api response: {_apiResponse.Content}"); }

 

In this case we send request in cycle and wait until UserName in the response equals to the expectedName

After small refactoring we get:

Code

Comments

Code

Comments

public void Then_user_name_updated(string expectedName) { bool expectedCondition(IRestResponse r) => r.AsJson<UserGetModel>().UserName.Equals(expectedName); var apiResponse = ApiClientUtils.ExecuteUntilResponseIs( () => _apiClient.GetAUser(_userId), expectedCondition, frequency: 2, timeoutSec: 10); Assert.True(expectedCondition(apiResponse), $"User name wasn't updated!\n" + $"Api response: {apiResponse.Content}"); }

 

This is satisfactory solution for checking single field was updated in the model

Working with multiple fields

Usually we need update multiple fields, let’s have a look how to deal with that

Feature class

Context class

Feature class

Context class

[Scenario(DisplayName = "Update user, multiple fields")] public void Update_user_multiple_fields() { Runner.WithContext<UserContext>().RunScenario( _ => _.Given_user_exists(), _ => _.When_user_model_updated_with_user_name("new user name"), _ => _.When_user_model_updated_with_first_name("new first name"), _ => _.When_user_model_updated_with_last_name("new last name"), _ => _.When_send_update_user_request(), _ => _.Then_response_code_is(HttpStatusCode.NoContent), _ => _.Then_user_updated() ); }

 

public void When_user_model_updated_with_user_name(string name) { _userModel.UserName = name; } public void When_user_model_updated_with_first_name(string firstName) { _userModel.FirstName = firstName; } public void When_user_model_updated_with_last_name(string lastName) { _userModel.LastName = lastName; } public void When_send_update_user_request() { _apiResponse = _apiClient.UpdateUser(_userId, _userModel.AsJson()); }

In the step Then_user_updated we need to check that API response contains the User entity with username, first and last names having new values, but there’re some challenges:

Firstly, we may have more then 3 values to compare (entity may consist of 10 and more fields)

Secondly, we will need to check as many fields in the API response as we updated in the test scenario

bool expectedCondition(IRestResponse r) => r.AsJson<UserGetModel>().UserName.Equals(expectedValue) && r.AsJson<UserGetModel>().FirstName.Equals(expectedValue) && r.AsJson<UserGetModel>().UserName.Equals(expectedValue);

This may be OK for a couple of fields but not for 10 and above, the code becomes hard for reading and maintaining (not mentioning the code writing rules)

Using “Equals“ method for objects comparison

The better way to compare two models instead of comparing by particular fields is using “Equals” method.

But before test updating, let’s have a small example of how “Equals“ method works

private class Rectangle { public int A { get; set; } public int B { get; set; } } [Fact] public void Test() { var rect1 = new Rectangle {A = 1, B = 2}; var rect2 = new Rectangle { A = 1, B = 2 }; var isEqual = rect1.Equals(rect2); // false Assert.Equal(rect1, rect2); // fails with en error: Message: Assert.Equal() Failure // Expected: Rectangle { A = 1, B = 2 } // Actual: Rectangle { A = 1, B = 2 } } [Fact] public void Test2() { var rect1 = new Rectangle { A = 1, B = 2 }; var rect2 =rect1; var isEqual = rect1.Equals(rect2); // true }

As you can see, by default, “Equals” method just checks weather two variables refer to the same instance of Rectangle class

To “say“ to “Equals“ method that you want to check not references but values you need to override “Equals” and “GetHashCode“ methods for the target class.

private class Rectangle { public int A { get; set; } public int B { get; set; } private bool Equals(Rectangle other) { return A == other.A && B == other.B; } public override bool Equals(object obj) { if (ReferenceEquals(null, obj)) return false; if (ReferenceEquals(this, obj)) return true; if (obj.GetType() != this.GetType()) return false; return Equals((Rectangle) obj); } public override int GetHashCode() { unchecked { return (A * 397) ^ B; } } }

Visual Studio has inbuilt tools to help in overriding “Equals” and “GetHashCode“ methods.

  1. Open “Generate code“ window (usually it’s buttons: Alt+Ins)

2. Select members of the class we will use for comparison of two objects and click “Finish“ button

“Equals” and “GetHashCode“ methods will be generated automatically and now we’re ready can compare two objects:

private class Rectangle { public int A { get; set; } public int B { get; set; } } [Fact] public void Test() { var rect1 = new Rectangle {A = 1, B = 2}; var rect2 = new Rectangle { A = 1, B = 2 }; var isEqual = rect1.Equals(rect2); // true }

 

Now let’s do the same for the UserModel class:

Feature class

Context class

Feature class

Context class

public class UserModel { public string FirstName { get; set; } public string LastName { get; set; } public string UserName { get; set; } public string Email { get; set; } public string ProviderUserId { get; set; } public int Status { get; set; } }

 

public class UserModel { public string FirstName { get; set; } public string LastName { get; set; } public string UserName { get; set; } public string Email { get; set; } public string ProviderUserId { get; set; } public int Status { get; set; } protected bool Equals(UserModel other) { return string.Equals(FirstName, other.FirstName) && string.Equals(LastName, other.LastName) && string.Equals(UserName, other.UserName) && string.Equals(Email, other.Email) && Status == other.Status; } public override bool Equals(object obj) { if (ReferenceEquals(null, obj)) return false; if (ReferenceEquals(this, obj)) return true; if (obj.GetType() != this.GetType()) return false; return Equals((UserModel) obj); } public override int GetHashCode() { unchecked { var hashCode = (FirstName != null ? FirstName.GetHashCode() : 0); hashCode = (hashCode * 397) ^ (LastName != null ? LastName.GetHashCode() : 0); hashCode = (hashCode * 397) ^ (UserName != null ? UserName.GetHashCode() : 0); hashCode = (hashCode * 397) ^ (Email != null ? Email.GetHashCode() : 0); hashCode = (hashCode * 397) ^ Status; return hashCode; } } }

Test with Then_user_updated implementation will look like this:

Feature class

Context class

Feature class

Context class

[Scenario(DisplayName = "Update user, multiple fields")] public void Update_user_multiple_fields() { Runner.WithContext<UserContext>().RunScenario( _ => _.Given_user_exists(), _ => _.When_user_model_updated_with_user_name("new user name"), _ => _.When_user_model_updated_with_first_name("new first name"), _ => _.When_user_model_updated_with_last_name("new last name"), _ => _.When_send_update_user_request(), _ => _.Then_response_code_is(HttpStatusCode.NoContent), _ => _.Then_user_updated() ); }

 

public void Then_user_updated() { var apiResponse = ApiClientUtils.ExecuteUntilResponseIs( () => _apiClient.GetAUser(_userId), r=>r.AsJson<UserModel>().Equals(_userModel), frequency: 2, timeoutSec: 10); var actualUser = apiResponse.AsJson<UserModel>(); AssertionUtils.AssertEqual(_userModel, actualUser, "User wasn't updated!"); }

In the method Then_user_updated we compare two instances of the UserModel class, first instance is _userModel (expected value), second, comes from API response of _apiClient.GetAUser(_userId) (actual value).

Implementation of Then_user_updated step may be simplified using AssertResponseContainsItem method, which comes from BaseContext class

Feature class

Context class

Feature class

Context class

[Scenario(DisplayName = "Update user, multiple fields")] public void Update_user_multiple_fields() { Runner.WithContext<UserContext>().RunScenario( _ => _.Given_user_exists(), _ => _.When_user_model_updated_with_user_name("new user name"), _ => _.When_user_model_updated_with_first_name("new first name"), _ => _.When_user_model_updated_with_last_name("new last name"), _ => _.When_send_update_user_request(), _ => _.Then_response_code_is(HttpStatusCode.NoContent), _ => _.Then_user_updated() ); }

 

public void Then_user_updated() { AssertResponseContainsItem(() => _apiClient.GetAUser(_userId), _userModel); }

Deleting entity

  • create request model

  • send create request

  • assert entity is accessible via read API

  • send delete request

  • send get request

  • assert entity was deleted

Test checking the deleting entity looks very similar to reading one.

The main point - after sending DELETE request we need to send GET request until it returns Not_Found status code:

Feature class

Context class

Feature class

Context class

[Scenario(DisplayName = "Delete a user")] public void Delete_a_user() { Runner.WithContext<UserContext>().RunScenario( _ => _.Given_user_exists(), _ => _.When_send_delete_user_request(), _ => _.Then_response_code_is(HttpStatusCode.NoContent), _ => _.And_get_a_user_request_returns(HttpStatusCode.NotFound) ); }

 

public void Given_user_exists() { Given_user_model(); When_send_create_user_request(); Then_response_code_is(HttpStatusCode.Created); And_get_a_user_request_returns(HttpStatusCode.OK); } public void When_send_delete_user_request() { _apiResponse = _apiClient.DeleteUser(_userId); } public void Then_response_code_is(HttpStatusCode expectedCode) { AssertResponseCodeIs(_apiResponse, expectedCode); } public void And_get_a_user_request_returns(HttpStatusCode expectedCode) { AssertRequestReturnsStatusCode( () => _apiClient.GetAUser(_userId), expectedCode, $"User with globalId {_userId} wasn't created!"); }

 

Sign off

@Jayasri (Unlicensed)
@Abdullah Arshad (Unlicensed)
@Denis Shevchenko (Deactivated)
@Kirill Martyshchenko (Unlicensed)
@Baz Chougule (Unlicensed)