Testing Elixir Applications: Best Practices and Tools
Testing is an essential part of software development, and Elixir developers are no strangers to the importance of writing robust, reliable, and maintainable tests. Elixir’s strong focus on testing, combined with its powerful built-in testing framework ExUnit, makes it a pleasure to test Elixir applications.
In this blog post, we will explore the best practices and tools for testing Elixir applications. Whether you are new to Elixir testing or an experienced developer, this guide will help you improve your testing workflow, increase your confidence in the code you write, and maintain a healthy, bug-free codebase.
1. Unit Testing in Elixir
Unit testing is the foundation of any testing strategy. It involves testing individual functions and modules in isolation to ensure they behave as expected. In Elixir, we use ExUnit, the built-in testing framework, for writing unit tests.
1.1 Writing Test Cases
Let’s begin by creating a simple unit test for a basic function:
elixir
defmodule MathUtilsTest do
use ExUnit.Case
test "add/2 should add two numbers" do
result = MathUtils.add(2, 3)
assert result == 5
end
end
1.2 Running Tests
To run the tests, execute the following command in your project’s root directory:
bash mix test
1.3 Test Case Organization
Organize your test cases by keeping them in separate test files and following a naming convention. For example, if your module is named MathUtils, create a corresponding math_utils_test.exs file.
2. Testing GenServers and Supervisors
GenServers and Supervisors are the building blocks of fault-tolerant Elixir applications. Testing them requires a slightly different approach, as they involve concurrent processes.
2.1 Mocking Dependencies
When testing GenServers, you often need to mock external dependencies like APIs or databases. The ExUnit.CallbackMock module allows you to mock these external calls and control the responses during testing.
elixir
defmodule MyGenServerTest do
use ExUnit.Case
import ExUnit.CallbackMock
setup do
{:ok, pid} = start_supervised(MyGenServer)
{:ok, pid: pid}
end
test "handle_request/1 should handle the request", %{pid: pid} do
with_mock MyApp.SomeModule, [fetch_data: fn _arg -> "Mocked Data" end] do
assert MyGenServer.handle_request(:some_data) == "Mocked Data"
end
end
end
2.2 Simulating Failures
Testing error conditions is crucial for building fault-tolerant applications. You can use ExUnit.Case.stub/3 to simulate failures and errors in your GenServer’s code.
elixir
defmodule MyGenServerTest do
use ExUnit.Case
test "handle_error/1 should handle errors gracefully" do
pid = spawn(fn -> MyGenServer.handle_error(:error_condition) end)
Process.exit(pid, :shutdown)
Process.sleep(100)
assert Process.alive?(pid) == false
end
end
2.3 Testing Supervision Trees
When testing a Supervisor, ensure that it restarts its child processes correctly upon failures. You can use ExUnit.Case.async to spawn and test child processes.
3. Integration Testing with ExUnit
Integration tests focus on the interactions between different parts of your application. In Elixir, integration tests are still written using ExUnit, but they often require additional setup and teardown steps.
3.1 Setting Up the Database
For applications that interact with databases, it’s essential to set up a separate test database to avoid conflicts with the development database. Ecto, the Elixir database wrapper, provides tools to achieve this.
elixir
defmodule MyAppIntegrationTest do
use ExUnit.Case, async: true
alias MyApp.Repo
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
Ecto.Adapters.SQL.Sandbox.mode(Repo, {:shared, self()})
end
on_exit fn ->
:ok = Ecto.Adapters.SQL.Sandbox.mode(Repo, :exit)
end
end
3.2 Seeding Test Data
To maintain a consistent state during tests, it’s a good practice to seed test data before running each test case. Use Ecto factories or simple database queries to achieve this.
elixir
defmodule MyAppIntegrationTest do
use ExUnit.Case, async: true
alias MyApp.{Repo, User}
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
Ecto.Adapters.SQL.Sandbox.mode(Repo, {:shared, self()})
# Seed test data
user = %User{name: "John Doe", email: "john@example.com"}
{:ok, user: Repo.insert(user)}
end
on_exit fn ->
:ok = Ecto.Adapters.SQL.Sandbox.mode(Repo, :exit)
end
end
3.3 Cleaning Up After Tests
Remember to clean up any data created during tests. The on_exit callback in the setup block ensures that the test data is removed after each test case.
4. Property-based Testing with PropEr
Property-based testing allows you to test your code with a wide range of inputs, automatically generating test cases. PropEr, a property-based testing tool for Elixir, enables you to identify edge cases and potential bugs.
4.1 Introduction to PropEr
PropEr uses a property specification to generate random test cases and verify the properties of your code. To get started, add the proper and proper_gen dependencies to your mix.exs file.
elixir
defp deps do
[
{:proper, "~> 1.4", only: :test},
{:proper_gen, "~> 1.0", only: :test}
]
end
4.2 Writing Property Tests
elixir
defmodule MathUtilsPropertyTest do
use ExUnitProperties
alias MathUtils, as: Utils
property "addition is commutative" do
check all a <- integers(), b <- integers() do
assert Utils.add(a, b) == Utils.add(b, a)
end
end
end
4.3 Fuzz Testing for Better Coverage
Property-based testing allows you to explore the input space effectively. By running tests with a large number of random inputs, you can find edge cases and improve the overall test coverage.
5. Test Coverage Analysis
Understanding your test coverage helps you identify areas of your code that lack testing. Coverex is a popular Elixir library that provides test coverage analysis for your projects.
5.1 Using Coverex
Add coverex to your mix.exs file and configure it to generate coverage reports.
elixir
defp deps do
[
{:coverex, "~> 1.5", only: :test}
]
end
defp test_opts do
[cover: [tool: Coverex]]
end
5.2 Interpreting Coverage Reports
After running tests, Coverex generates coverage reports in the cover directory. Open the HTML report to visualize which parts of your code are well-covered and which require more testing.
5.3 Improving Code Coverage
Use the insights gained from coverage reports to improve your test suite. Aim for high test coverage to increase your confidence in the codebase’s correctness.
6. Continuous Integration and Elixir Testing
Integrating testing into your continuous integration (CI) workflow is crucial to ensure your tests are automatically executed with every code change.
6.1 Integrating with CI/CD Pipelines
Most CI tools support Elixir out of the box. Configure your CI/CD pipeline to run tests on every pull request, merge, or any other event that triggers a build.
6.2 Automating Testing on Pull Requests
Require passing tests before merging pull requests to maintain the quality and reliability of your codebase.
6.3 Handling Parallel Test Execution
To speed up your test suite, consider running tests in parallel. Elixir’s lightweight processes make parallel testing highly efficient.
Conclusion
Testing is an integral part of Elixir development, and with the right tools and practices, you can ensure that your applications are stable and reliable. From unit testing to property-based testing and test coverage analysis, Elixir provides a robust testing ecosystem. Incorporate these best practices and tools into your workflow to level up your Elixir testing game and build high-quality applications with confidence. Happy testing!
Table of Contents


