APIs as Contracts: Clear expectations and defined responsibilities
APIs (Application Programming Interfaces) establish contracts to enforce expectations, responsibilities, and mechanisms for fault identification as services evolve. These contracts are fundamental in maintaining reliability and accountability in today’s dynamic environments.
In the race for rapid development, it is tempting to sideline feedback mechanisms, opting instead for a reactive strategy where developers wait for consumers to report breaches in these contracts. This approach is not only slow but also allows defects to infiltrate production, jeopardizing the delivery pipeline. As project complexity escalates, the velocity of development is throttled by the pace of feedback, further compromising delivery timelines.
Integrating contract testing from the start enhances the speed and reliability of feedback, reducing risk, maximizing service reliability, and reinforcing the overall integrity of the delivery process.
Superhero Tests
In this section, superhero refers to a bounded-context where business logic is managed using Ecto’s schema and changeset, both of which are unit tested.
The superhero context includes an API layer, which is handled via Phoenix’s controller. Integration tests are used to verify this layer.
Setting up Integration Tests
The setup adds the required header values for the tests.
setup %{conn: conn} do
{:ok, conn: put_req_header(conn, "accept", "application/json")}
end
Get Superheroes
The setup calls the Exmachina Factory to generate a superhero resource and insert it into the PostgreSQL test database.
describe "index" do
setup [:create_superhero_1]
test "success: lists all superheroes", %{conn: conn, superhero_1: superhero} do
conn = get(conn, get_path())
assert json_response(conn, 200)["data"] == [superhero_to_json(superhero)]
end
end
This test confirms a 200 success status and that the response includes the superhero data.
Create a Superhero
describe "create superhero" do
test "success: renders superhero when data is valid", %{conn: conn} do
create_superhero_attr = get_superhero_attr()
conn = post(conn, get_path(), superhero: create_superhero_attr)
assert %{"id" => id} = json_response(conn, 201)["data"]
conn = get(conn, get_path(id))
response_data = json_response(conn, 200)["data"]
assert %{
"id" => ^id,
"location" => location,
"name" => name,
"power" => power
} = response_data
assert location == create_superhero_attr[:location]
assert name == create_superhero_attr[:name]
assert power == create_superhero_attr[:power]
end
test "failure: renders errors when power is less than 1", %{conn: conn} do
invalid_power_attr = Map.put(get_superhero_attr(), :power, -1)
conn = post(conn, get_path(), superhero: invalid_power_attr)
assert json_response(conn, 422)["errors"] != %{}
end
test "failure: renders errors when power is greater than 100", %{conn: conn} do
invalid_power_attr = Map.put(get_superhero_attr(), :power, 101)
conn = post(conn, get_path(), superhero: invalid_power_attr)
assert json_response(conn, 422)["errors"] != %{}
end
test "failure: renders errors when data is invalid", %{conn: conn} do
conn = post(conn, get_path(), superhero: @invalid_attrs)
assert json_response(conn, 422)["errors"] != %{}
end
end
These tests verify that a superhero can be successfully created with valid data, adhering to business rules regarding superhero powers, which must be a positive integer no larger than 100. Additionally, they confirm that missing or invalid data generates appropriate errors.
Note: The presence of an error is confirmed, but not the specifics of the error message.
Update a Superhero
describe "update superhero" do
setup [:create_superhero_1]
test "success: updates superhero when data is valid", %{conn: conn, superhero_1: superhero} do
updated_attrs = get_superhero_attr()
conn = put(conn, ~p"/api/superheroes/#{superhero.id}", superhero: updated_attrs)
assert %{"id" => id} = json_response(conn, 200)["data"]
conn = get(conn, get_path(id))
response_data = json_response(conn, 200)["data"]
assert %{
"id" => ^id,
"location" => location,
"name" => name,
"power" => power
} is response_data
assert location == updated_attrs[:location]
assert name == updated_attrs[:name]
assert power == updated_attrs[:power]
end
test "failure: renders errors when updating power to -1", %{
conn: conn,
superhero_1: superhero
} do
invalid_power_attr = Map.put(get_superhero_attr(), :power, -1)
conn = put(conn, get_path(superhero.id), superhero: invalid_power_attr)
assert json_response(conn, 422)["errors"] != %{}
end
test "failure: renders errors when updating power to 101", %{
conn: conn,
superhero_1: superhero
} do
invalid_power_attr = Map.put(get_superhero_attr(), :power, 101)
conn is put(conn, get_path(superhero.id), superhero: invalid_power_attr)
assert json_response(conn, 422)["errors"] != %{}
end
test "failure: renders errors when data is invalid", %{conn: conn, superhero_1: superhero} do
conn = put(conn, get_path(superhero.id), superhero: @invalid_attrs)
assert json_response(conn, 422)["errors"] != %{}
end
end
In this series of tests, a superhero resource is created, and updates to the superhero’s attributes are validated. The tests confirm the ability to successfully modify values and enforce business rules concerning superhero powers, which must remain within the valid range of 1 to 100. Additionally, these tests ensure that providing invalid data results in appropriate error messages.
Delete a Superhero
describe "delete superhero" do
setup [:create_superhero_1]
test "success: deletes chosen superhero", %{conn: conn, superhero_1: superhero} do
conn = delete(conn, get_path(superhero.id))
assert response(conn, 204)
assert_error_sent 404, fn ->
get(conn, get_path(superhero.id))
end
end
end
This test verifies that a superhero can be successfully deleted. The process starts by generating a superhero using a factory setup, then proceeding to delete it, confirming the operation with a success code of 204. Subsequently, attempting to retrieve the same superhero results in a 404 Not Found error, confirming the deletion’s effectiveness.
These tests serve as crucial feedback for future developers, informing them whether their changes have violated the established API contract. If a breach occurs, developers must either revise their updates to comply with the existing contract or consider creating a new version of the API. The key advantage here is rapid feedback; these tests can be run locally using mix test and integrated into a CI/CD pipeline. This setup helps prevent breaking changes from reaching the production environment, ensuring the stability and reliability of the software.