Elixir Functions

 

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.

Testing Elixir Applications: Best Practices and Tools

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!

Previously at
Flag Argentina
Brazil
time icon
GMT-3
Tech Lead in Elixir with 3 years' experience. Passionate about Elixir/Phoenix and React Native. Full Stack Engineer, Event Organizer, Systems Analyst, Mobile Developer.