From 3ac7c51bd8312e9910f9f70547e821077e51eefe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jarl=20Andr=C3=A9=20H=C3=BCbenthal?= Date: Thu, 19 Oct 2023 18:26:46 +0200 Subject: [PATCH] add an ecto macro for use in phoenix apps --- README.md | 19 ++++ lib/container.ex | 4 +- lib/container/ceph_container.ex | 12 +++ lib/container/mysql_container.ex | 12 +++ lib/container/postgres_container.ex | 12 +++ lib/ecto.ex | 162 ++++++++++++++++++++++++++++ mix.exs | 1 + mix.lock | 2 + 8 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 lib/ecto.ex diff --git a/README.md b/README.md index 5081fd8..bc30f98 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,25 @@ end This section explains how to use the Testcontainers library in your own project. +### In a Phoenix project: + +In simple terms you can add this in application.ex: + +```elixir + # In your application.ex file in your Phoenix project: + + import Testcontainers.Ecto + + @impl true + def start(_type, _args) do + postgres_container(app: :my_app), + + # .. other setup code + end +``` + +see documentation on Testcontainers.Ecto for more information. + ### Simple example Here's a simple example of how to use a MySQL container in your tests: diff --git a/lib/container.ex b/lib/container.ex index 546a442..0986b78 100644 --- a/lib/container.ex +++ b/lib/container.ex @@ -68,10 +68,12 @@ defmodule Testcontainers.Container do def with_fixed_port(%__MODULE__{} = config, port, host_port \\ nil) when is_integer(port) and (is_nil(host_port) or is_integer(host_port)) do + filtered_ports = config.exposed_ports |> Enum.filter(fn port -> port != port end) + %__MODULE__{ config | exposed_ports: [ - {port, host_port || port} | config.exposed_ports + {port, host_port || port} | filtered_ports ] } end diff --git a/lib/container/ceph_container.ex b/lib/container/ceph_container.ex index 604ca50..e7dbd69 100644 --- a/lib/container/ceph_container.ex +++ b/lib/container/ceph_container.ex @@ -25,6 +25,18 @@ defmodule Testcontainers.Container.CephContainer do |> Container.with_waiting_strategies(wait_strategies(8080, bucket)) end + def with_bucket(%Container{} = container, bucket) when is_binary(bucket) do + %{container | environment: Map.put(container.environment, :CEPH_DEMO_BUCKET, bucket)} + end + + def with_access_key(%Container{} = container, access_key) when is_binary(access_key) do + %{container | environment: Map.put(container.environment, :CEPH_DEMO_ACCESS_KEY, access_key)} + end + + def with_secret_key(%Container{} = container, secret_key) when is_binary(secret_key) do + %{container | environment: Map.put(container.environment, :CEPH_DEMO_SECRET_KEY, secret_key)} + end + defp wait_strategies(port, bucket) do [ LogWaitStrategy.new( diff --git a/lib/container/mysql_container.ex b/lib/container/mysql_container.ex index 9535b5b..9988a71 100644 --- a/lib/container/mysql_container.ex +++ b/lib/container/mysql_container.ex @@ -40,6 +40,18 @@ defmodule Testcontainers.Container.MySqlContainer do |> Container.with_waiting_strategy(wait_strategy(username, password)) end + def with_user(%Container{} = container, user) when is_binary(user) do + %{container | environment: Map.put(container.environment, :MYSQL_USER, user)} + end + + def with_password(%Container{} = container, password) when is_binary(password) do + %{container | environment: Map.put(container.environment, :MYSQL_PASSWORD, password)} + end + + def with_database(%Container{} = container, database) when is_binary(database) do + %{container | environment: Map.put(container.environment, :MYSQL_DATABASE, database)} + end + @doc """ Returns the port on the _host machine_ where the MySql container is listening. """ diff --git a/lib/container/postgres_container.ex b/lib/container/postgres_container.ex index 273d51d..29c54be 100644 --- a/lib/container/postgres_container.ex +++ b/lib/container/postgres_container.ex @@ -39,6 +39,18 @@ defmodule Testcontainers.Container.PostgresContainer do |> Container.with_waiting_strategy(wait_strategy(username, database)) end + def with_user(%Container{} = container, user) when is_binary(user) do + %{container | environment: Map.put(container.environment, :POSTGRES_USER, user)} + end + + def with_password(%Container{} = container, password) when is_binary(password) do + %{container | environment: Map.put(container.environment, :POSTGRES_PASSWORD, password)} + end + + def with_database(%Container{} = container, database) when is_binary(database) do + %{container | environment: Map.put(container.environment, :POSTGRES_DB, database)} + end + @doc """ Returns the port on the _host machine_ where the MySql container is listening. """ diff --git a/lib/ecto.ex b/lib/ecto.ex new file mode 100644 index 0000000..b1c9501 --- /dev/null +++ b/lib/ecto.ex @@ -0,0 +1,162 @@ +defmodule Testcontainers.Ecto do + @moduledoc """ + Facilitates the creation of a Postgres container for testing with Ecto. + + This module simplifies the process of launching a real Postgres database instance within a Docker container for testing purposes. It leverages the `Testcontainers` library to instantiate a Postgres container with the desired configuration, providing an isolated database environment for each test session. + """ + + @doc """ + Initiates a new Postgres instance, executes migrations, and prepares a suitable database environment, specifically tailored for testing scenarios. + + ## Parameters + + - `options`: Configurations for the Postgres container, provided as a keyword list. The only required option is `:app`. Other options include: + - `:app` - The current application's atom, necessary for building paths and other application-specific logic. This is a required parameter. + - `:repo` (optional) - The Ecto repository module for database interaction. If not provided, it is inferred from the `:app` option using the default naming convention (e.g., `MyApp.Repo`). + - `:image` (optional) - Specifies the Docker image for the Postgres container. This must be a legitimate Postgres image, with the image name beginning with "postgres". If omitted, the default is "postgres:15". + - `:port` (optional) - Designates the host port for the Postgres service (defaults to 5432). + - `:user` (optional) - Sets the username for the Postgres instance (defaults to "postgres"). + - `:password` (optional) - Determines the password for the Postgres user (defaults to "postgres"). + - `:database` (optional) - Specifies the name of the database to be created within the Postgres instance. If not provided, the default behavior is to create a database with the name derived from the application's atom, appended with "_test". + - `:migrations_path` (optional) - Indicates the path to the migrations folder (defaults to "priv/repo/migrations"). + + ## Database Lifecycle in Testing + + It's important to note that the Postgres database initiated by this macro will remain operational for the duration of the test process and is not explicitly shut down by the macro. The database and its corresponding data are ephemeral, lasting only for the scope of the test session. + + After the tests conclude, Testcontainers will clean up by removing the database container, ensuring no residual data persists. This approach helps maintain a clean testing environment and prevents any unintended side effects on subsequent tests due to data leftovers. + + Users should not rely on any manual teardown or cleanup for the database, as Testcontainers handles this aspect automatically, providing isolated, repeatable test runs. + + ## Examples + + # In your application.ex file in your Phoenix project: + + import Testcontainers.Ecto + + @impl true + def start(_type, _args) do + postgres_container(app: :my_app), + + # .. other setup code + end + + # In mix.exs, modify the aliases to remove default Ecto setup tasks from the test alias, + # as they might interfere with the container-based database setup: + + def aliases do + [ + # ... other aliases + + # Ensure the following line is NOT present, as it would conflict with the container setup: + # test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], + ] + end + + # in your config/test.exs, if you want to keep appending the MIX_TEST_PARTITION env variable to the database name, + # you must set the database option in postgres_container macro to the same value + + config :my_app, MyApp.Repo, + username: "postgres", + password: "postgres", + hostname: "localhost", + database: "my_app_test#{System.get_env("MIX_TEST_PARTITION")}", # set this also in postgres_container macro database option, or remove the appending + pool: Ecto.Adapters.SQL.Sandbox, + pool_size: 10 + + # for example, to set the database name to the one above, in application.ex: + + @impl true + def start(_type, _args) do + postgres_container(app: :my_app, database: "my_app_test#{System.get_env("MIX_TEST_PARTITION")}"), + + # .. other setup code + ] + + ## Returns + + - `:ok` if the container is initiated successfully. + - `{:error, reason}` if there is a failure in initiating the container, with `reason` explaining the cause of the failure. + + ## Errors + + - Raises `ArgumentError` if the application is missing, not an atom, or not loaded. + - Raises `ArgumentError` if the repo is defined and not an atom + - Raises `ArgumentError` if the specified Docker image is not a valid Postgres image. + + ## Note + + This utility is intended for testing environments requiring a genuine database instance. It is not suitable for production use. It mandates a valid Postgres Docker image to maintain consistent and reliable testing conditions. + """ + defmacro postgres_container(options \\ []) do + alias Testcontainers.Container.PostgresContainer + alias Testcontainers.Container + import Testcontainers.ExUnit + + app = Keyword.get(options, :app) + + if app == nil or not is_atom(app) or + Application.ensure_loaded(app) != :ok do + raise ArgumentError, + "Missing or ot an application: #{inspect(app)}" + end + + repo = Keyword.get(options, :repo) + + if repo != nil and not is_atom(repo) do + raise ArgumentError, + "Not an atom: #{inspect(repo)}" + end + + repo = + if is_nil(repo) do + repo_name = (app |> Atom.to_string() |> camelize()) <> ".Repo" + Module.concat(Elixir, String.to_atom(repo_name)) + else + repo + end + + image = Keyword.get(options, :image, "postgres:15") + + if !String.starts_with?(image, "postgres") do + raise ArgumentError, + "The provided Docker image '#{image}' is not a recognized Postgres image." + end + + host_port = Keyword.get(options, :port, 5432) + user = Keyword.get(options, :user, "postgres") + password = Keyword.get(options, :password, "postgres") + database = Keyword.get(options, :database, "#{Atom.to_string(app)}_test") + migrations_path = Keyword.get(options, :migrations_path, "priv/repo/migrations") + + quote do + container = + PostgresContainer.new(unquote(image)) + |> Container.with_fixed_port(unquote(host_port)) + |> PostgresContainer.with_user(unquote(user)) + |> PostgresContainer.with_password(unquote(password)) + |> PostgresContainer.with_database(unquote(database)) + + case run_container(container, on_exit: nil) do + {:ok, _} -> + {:ok, pid} = unquote(repo).start_link() + + absolute_migrations_path = + Application.app_dir(unquote(app), unquote(migrations_path)) + + Ecto.Migrator.run(unquote(repo), absolute_migrations_path, :up, all: true) + GenServer.stop(pid) + + {:error, reason} -> + {:error, reason} + end + end + end + + defp camelize(string) do + string + |> String.split("_") + |> Enum.map(&String.capitalize/1) + |> Enum.join() + end +end diff --git a/mix.exs b/mix.exs index b4af5a7..5719172 100644 --- a/mix.exs +++ b/mix.exs @@ -39,6 +39,7 @@ defmodule TestcontainersElixir.MixProject do defp deps do [ {:ex_docker_engine_api, "~> 1.43"}, + {:ecto_sql, "~> 3.10"}, {:dialyxir, "~> 1.3", only: [:dev], runtime: false}, {:ex_doc, "~> 0.30", only: :dev, runtime: false}, {:myxql, "~> 0.6.0", only: [:dev, :test]}, diff --git a/mix.lock b/mix.lock index 7ded568..5662d4f 100644 --- a/mix.lock +++ b/mix.lock @@ -7,6 +7,8 @@ "docker_api": {:git, "https://github.com/hexedpackets/docker-elixir.git", "b4f8b929691ce2bf9f11743def49009de460431b", [tag: "0.4.0"]}, "docker_engine_api": {:git, "https://github.com/jarlah/docker-engine-api-elixir.git", "9d5220000423e92b5074ce2f54cd558da21a1dc7", [tag: "1.43.0.5-hackney"]}, "earmark_parser": {:hex, :earmark_parser, "1.4.37", "2ad73550e27c8946648b06905a57e4d454e4d7229c2dafa72a0348c99d8be5f7", [:mix], [], "hexpm", "6b19783f2802f039806f375610faa22da130b8edc21209d0bff47918bb48360e"}, + "ecto": {:hex, :ecto, "3.10.3", "eb2ae2eecd210b4eb8bece1217b297ad4ff824b4384c0e3fdd28aaf96edd6135", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "44bec74e2364d491d70f7e42cd0d690922659d329f6465e89feb8a34e8cd3433"}, + "ecto_sql": {:hex, :ecto_sql, "3.10.2", "6b98b46534b5c2f8b8b5f03f126e75e2a73c64f3c071149d32987a5378b0fdbd", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.10.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "68c018debca57cb9235e3889affdaec7a10616a4e3a80c99fa1d01fdafaa9007"}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "ex_doc": {:hex, :ex_doc, "0.30.8", "cf3eb2eb32137966aab0929bb3af42773b2d08e2f785a5fee9caabf664082cb3", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "bfb981d8e0a8ab23857e502d611c612ae2c24536dd3b530e741d1d94ea44e6e2"}, "ex_docker_engine_api": {:hex, :ex_docker_engine_api, "1.43.0", "a0dbcb509732247ab6925f0429a3516c1fe27561f21b29182cfc69d7b32fc516", [:mix], [{:hackney, "~> 1.20", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:tesla, "~> 1.7", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "278a05f8f1d5f5b5738801cd96287583228fbaf8e6a9aef30176a5b37544f8ba"},