logo

pleroma

My custom branche(s) on git.pleroma.social/pleroma/pleroma git clone https://anongit.hacktivis.me/git/pleroma.git/

mox_testing.md (17476B)


  1. # Using Mox for Testing in Pleroma
  2. ## Introduction
  3. This guide explains how to use [Mox](https://hexdocs.pm/mox/Mox.html) for testing in Pleroma and how to migrate existing tests from Mock/meck to Mox. Mox is a library for defining concurrent mocks in Elixir that offers several key advantages:
  4. - **Async-safe testing**: Mox supports concurrent testing with `async: true`
  5. - **Explicit contract through behaviors**: Enforces implementation of behavior callbacks
  6. - **No module redefinition**: Avoids runtime issues caused by redefining modules
  7. - **Expectations scoped to the current process**: Prevents test state from leaking between tests
  8. ## Why Migrate from Mock/meck to Mox?
  9. ### Problems with Mock/meck
  10. 1. **Not async-safe**: Tests using Mock/meck cannot safely run with `async: true`, which slows down the test suite
  11. 2. **Global state**: Mocked functions are global, leading to potential cross-test contamination
  12. 3. **No explicit contract**: No guarantee that mocked functions match the actual implementation
  13. 4. **Module redefinition**: Can lead to hard-to-debug runtime issues
  14. ### Benefits of Mox
  15. 1. **Async-safe testing**: Tests can run concurrently with `async: true`, significantly speeding up the test suite
  16. 2. **Process isolation**: Expectations are set per process, preventing leakage between tests
  17. 3. **Explicit contracts via behaviors**: Ensures mocks implement all required functions
  18. 4. **Compile-time checks**: Prevents mocking non-existent functions
  19. 5. **No module redefinition**: Mocks are defined at compile time, not runtime
  20. ## Existing Mox Setup in Pleroma
  21. Pleroma already has a basic Mox setup in the `Pleroma.DataCase` module, which handles some common mocking scenarios automatically. Here's what's included:
  22. ### Default Mox Configuration
  23. The `setup` function in `DataCase` does the following:
  24. 1. Sets up Mox for either async or non-async tests
  25. 2. Verifies all mock expectations on test exit
  26. 3. Stubs common dependencies with their real implementations
  27. ```elixir
  28. # From test/support/data_case.ex
  29. setup tags do
  30. setup_multi_process_mode(tags)
  31. setup_streamer(tags)
  32. stub_pipeline()
  33. Mox.verify_on_exit!()
  34. :ok
  35. end
  36. ```
  37. ### Async vs. Non-Async Test Setup
  38. Pleroma configures Mox differently depending on whether your test is async or not:
  39. ```elixir
  40. def setup_multi_process_mode(tags) do
  41. :ok = Ecto.Adapters.SQL.Sandbox.checkout(Pleroma.Repo)
  42. if tags[:async] do
  43. # For async tests, use process-specific mocks and stub CachexMock with NullCache
  44. Mox.stub_with(Pleroma.CachexMock, Pleroma.NullCache)
  45. Mox.set_mox_private()
  46. else
  47. # For non-async tests, use global mocks and stub CachexMock with CachexProxy
  48. Ecto.Adapters.SQL.Sandbox.mode(Pleroma.Repo, {:shared, self()})
  49. Mox.set_mox_global()
  50. Mox.stub_with(Pleroma.CachexMock, Pleroma.CachexProxy)
  51. clear_cachex()
  52. end
  53. :ok
  54. end
  55. ```
  56. ### Default Pipeline Stubs
  57. Pleroma automatically stubs several core components with their real implementations:
  58. ```elixir
  59. def stub_pipeline do
  60. Mox.stub_with(Pleroma.Web.ActivityPub.SideEffectsMock, Pleroma.Web.ActivityPub.SideEffects)
  61. Mox.stub_with(Pleroma.Web.ActivityPub.ObjectValidatorMock, Pleroma.Web.ActivityPub.ObjectValidator)
  62. Mox.stub_with(Pleroma.Web.ActivityPub.MRFMock, Pleroma.Web.ActivityPub.MRF)
  63. Mox.stub_with(Pleroma.Web.ActivityPub.ActivityPubMock, Pleroma.Web.ActivityPub.ActivityPub)
  64. Mox.stub_with(Pleroma.Web.FederatorMock, Pleroma.Web.Federator)
  65. Mox.stub_with(Pleroma.ConfigMock, Pleroma.Config)
  66. Mox.stub_with(Pleroma.StaticStubbedConfigMock, Pleroma.Test.StaticConfig)
  67. Mox.stub_with(Pleroma.StubbedHTTPSignaturesMock, Pleroma.Test.HTTPSignaturesProxy)
  68. end
  69. ```
  70. This means that by default, these mocks will behave like their real implementations unless you explicitly override them with expectations in your tests.
  71. ### Understanding Config Mock Types
  72. Pleroma has three different Config mock implementations, each with a specific purpose and different characteristics regarding async test safety:
  73. #### 1. ConfigMock
  74. - Defined in `test/support/mocks.ex` as `Mox.defmock(Pleroma.ConfigMock, for: Pleroma.Config.Getting)`
  75. - It's stubbed with the real `Pleroma.Config` by default in `DataCase`: `Mox.stub_with(Pleroma.ConfigMock, Pleroma.Config)`
  76. - This means it falls back to the normal configuration behavior unless explicitly overridden
  77. - Used for general mocking of configuration in tests where you want most config to behave normally
  78. - ⚠️ **NOT ASYNC-SAFE**: Since it's stubbed with the real `Pleroma.Config`, it modifies global application state
  79. - Can not be used in tests with `async: true`
  80. #### 2. StaticStubbedConfigMock
  81. - Defined in `test/support/mocks.ex` as `Mox.defmock(Pleroma.StaticStubbedConfigMock, for: Pleroma.Config.Getting)`
  82. - It's stubbed with `Pleroma.Test.StaticConfig` (defined in `test/test_helper.exs`)
  83. - `Pleroma.Test.StaticConfig` creates a completely static configuration snapshot at the start of the test run:
  84. ```elixir
  85. defmodule Pleroma.Test.StaticConfig do
  86. @moduledoc """
  87. This module provides a Config that is completely static, built at startup time from the environment.
  88. It's safe to use in testing as it will not modify any state.
  89. """
  90. @behaviour Pleroma.Config.Getting
  91. @config Application.get_all_env(:pleroma)
  92. def get(path, default \\ nil) do
  93. get_in(@config, path) || default
  94. end
  95. end
  96. ```
  97. - Configuration is frozen at startup time and doesn't change during the test run
  98. - ✅ **ASYNC-SAFE**: Never modifies global state since it uses a frozen snapshot of the configuration
  99. #### 3. UnstubbedConfigMock
  100. - Defined in `test/support/mocks.ex` as `Mox.defmock(Pleroma.UnstubbedConfigMock, for: Pleroma.Config.Getting)`
  101. - Unlike the other two mocks, it's not automatically stubbed with any implementation in `DataCase`
  102. - Starts completely "unstubbed" and requires tests to explicitly set expectations or stub it
  103. - The most commonly used configuration mock in the test suite
  104. - Often aliased as `ConfigMock` in individual test files: `alias Pleroma.UnstubbedConfigMock, as: ConfigMock`
  105. - Set as the default config implementation in `config/test.exs`: `config :pleroma, :config_impl, Pleroma.UnstubbedConfigMock`
  106. - Offers maximum flexibility for tests that need precise control over configuration values
  107. - ✅ **ASYNC-SAFE**: Safe if used with `expect()` to set up test-specific expectations (since expectations are process-scoped)
  108. #### Configuring Components to Use Specific Mocks
  109. In `config/test.exs`, different components can be configured to use different configuration mocks:
  110. ```elixir
  111. # Components using UnstubbedConfigMock
  112. config :pleroma, Pleroma.Upload, config_impl: Pleroma.UnstubbedConfigMock
  113. config :pleroma, Pleroma.User.Backup, config_impl: Pleroma.UnstubbedConfigMock
  114. config :pleroma, Pleroma.Uploaders.S3, config_impl: Pleroma.UnstubbedConfigMock
  115. # Components using StaticStubbedConfigMock (async-safe)
  116. config :pleroma, Pleroma.Language.LanguageDetector, config_impl: Pleroma.StaticStubbedConfigMock
  117. config :pleroma, Pleroma.Web.RichMedia.Helpers, config_impl: Pleroma.StaticStubbedConfigMock
  118. config :pleroma, Pleroma.Web.Plugs.HTTPSecurityPlug, config_impl: Pleroma.StaticStubbedConfigMock
  119. ```
  120. This allows different parts of the application to use the most appropriate configuration mocking strategy based on their specific needs.
  121. #### When to Use Each Config Mock Type
  122. - **ConfigMock**: ⚠️ For non-async tests only, when you want most configuration to behave normally with occasional overrides
  123. - **StaticStubbedConfigMock**: ✅ For async tests where modifying global state would be problematic and a static configuration is sufficient
  124. - **UnstubbedConfigMock**: ⚠️ Use carefully in async tests; set specific expectations rather than stubbing with implementations that modify global state
  125. #### Summary of Async Safety
  126. | Mock Type | Async-Safe? | Best Use Case |
  127. |-----------|-------------|--------------|
  128. | ConfigMock | ❌ No | Non-async tests that need minimal configuration overrides |
  129. | StaticStubbedConfigMock | ✅ Yes | Async tests that need configuration values without modification |
  130. | UnstubbedConfigMock | ⚠️ Depends | Any test with careful usage; set expectations rather than stubbing |
  131. ## Configuration in Async Tests
  132. ### Understanding `clear_config` Limitations
  133. The `clear_config` helper is commonly used in Pleroma tests to modify configuration for specific tests. However, it's important to understand that **`clear_config` is not async-safe** and should not be used in tests with `async: true`.
  134. Here's why:
  135. ```elixir
  136. # Implementation of clear_config in test/support/helpers.ex
  137. defmacro clear_config(config_path, temp_setting) do
  138. quote do
  139. clear_config(unquote(config_path)) do
  140. Config.put(unquote(config_path), unquote(temp_setting))
  141. end
  142. end
  143. end
  144. defmacro clear_config(config_path, do: yield) do
  145. quote do
  146. initial_setting = Config.fetch(unquote(config_path))
  147. unquote(yield)
  148. on_exit(fn ->
  149. case initial_setting do
  150. :error ->
  151. Config.delete(unquote(config_path))
  152. {:ok, value} ->
  153. Config.put(unquote(config_path), value)
  154. end
  155. end)
  156. :ok
  157. end
  158. end
  159. ```
  160. The issue is that `clear_config`:
  161. 1. Modifies the global application environment
  162. 2. Uses `on_exit` to restore the original value after the test
  163. 3. Can lead to race conditions when multiple async tests modify the same configuration
  164. ### Async-Safe Configuration Approaches
  165. When writing async tests with Mox, use these approaches instead of `clear_config`:
  166. 1. **Dependency Injection with Module Attributes**:
  167. ```elixir
  168. # In your module
  169. @config_impl Application.compile_env(:pleroma, [__MODULE__, :config_impl], Pleroma.Config)
  170. def some_function do
  171. value = @config_impl.get([:some, :config])
  172. # ...
  173. end
  174. ```
  175. 2. **Mock the Config Module**:
  176. ```elixir
  177. # In your test
  178. Pleroma.ConfigMock
  179. |> expect(:get, fn [:some, :config] -> "test_value" end)
  180. ```
  181. 3. **Use Test-Specific Implementations**:
  182. ```elixir
  183. # Define a test-specific implementation
  184. defmodule TestConfig do
  185. def get([:some, :config]), do: "test_value"
  186. def get(_), do: nil
  187. end
  188. # In your test
  189. Mox.stub_with(Pleroma.ConfigMock, TestConfig)
  190. ```
  191. 4. **Pass Configuration as Arguments**:
  192. ```elixir
  193. # Refactor functions to accept configuration as arguments
  194. def some_function(config \\ nil) do
  195. config = config || Pleroma.Config.get([:some, :config])
  196. # ...
  197. end
  198. # In your test
  199. some_function("test_value")
  200. ```
  201. By using these approaches, you can safely run tests with `async: true` without worrying about configuration conflicts.
  202. ## Setting Up Mox in Pleroma
  203. ### Step 1: Define a Behavior
  204. Start by defining a behavior for the module you want to mock. This specifies the contract that both the real implementation and mocks must follow.
  205. ```elixir
  206. # In your implementation module (e.g., lib/pleroma/uploaders/s3.ex)
  207. defmodule Pleroma.Uploaders.S3.ExAwsAPI do
  208. @callback request(op :: ExAws.Operation.t()) :: {:ok, ExAws.Operation.t()} | {:error, term()}
  209. end
  210. ```
  211. ### Step 2: Make Your Implementation Configurable
  212. Modify your module to use a configurable implementation. This allows for dependency injection and easier testing.
  213. ```elixir
  214. # In your implementation module
  215. @ex_aws_impl Application.compile_env(:pleroma, [__MODULE__, :ex_aws_impl], ExAws)
  216. @config_impl Application.compile_env(:pleroma, [__MODULE__, :config_impl], Pleroma.Config)
  217. def put_file(%Pleroma.Upload{} = upload) do
  218. # Use @ex_aws_impl instead of ExAws directly
  219. case @ex_aws_impl.request(op) do
  220. {:ok, _} ->
  221. {:ok, {:file, s3_name}}
  222. error ->
  223. Logger.error("#{__MODULE__}: #{inspect(error)}")
  224. error
  225. end
  226. end
  227. ```
  228. ### Step 3: Define the Mock in test/support/mocks.ex
  229. Add your mock definition in the central mocks file:
  230. ```elixir
  231. # In test/support/mocks.ex
  232. Mox.defmock(Pleroma.Uploaders.S3.ExAwsMock, for: Pleroma.Uploaders.S3.ExAwsAPI)
  233. ```
  234. ### Step 4: Configure the Mock in Test Environment
  235. In your test configuration (e.g., `config/test.exs`), specify which mock implementation to use:
  236. ```elixir
  237. config :pleroma, Pleroma.Uploaders.S3, ex_aws_impl: Pleroma.Uploaders.S3.ExAwsMock
  238. config :pleroma, Pleroma.Uploaders.S3, config_impl: Pleroma.UnstubbedConfigMock
  239. ```
  240. ## Writing Tests with Mox
  241. ### Setting Up Your Test
  242. ```elixir
  243. defmodule Pleroma.Uploaders.S3Test do
  244. use Pleroma.DataCase, async: true # Note: async: true is now possible!
  245. alias Pleroma.Uploaders.S3
  246. alias Pleroma.Uploaders.S3.ExAwsMock
  247. alias Pleroma.UnstubbedConfigMock, as: ConfigMock
  248. import Mox # Import Mox functions
  249. # Note: verify_on_exit! is already called in DataCase setup
  250. # so you don't need to add it explicitly in your test module
  251. end
  252. ```
  253. ### Setting Expectations with Mox
  254. Mox uses an explicit expectation system. Here's how to use it:
  255. ```elixir
  256. # Basic expectation for a function call
  257. ExAwsMock
  258. |> expect(:request, fn _req -> {:ok, %{status_code: 200}} end)
  259. # Expectation for multiple calls with same response
  260. ExAwsMock
  261. |> expect(:request, 3, fn _req -> {:ok, %{status_code: 200}} end)
  262. # Expectation with specific arguments
  263. ExAwsMock
  264. |> expect(:request, fn %{bucket: "test_bucket"} -> {:ok, %{status_code: 200}} end)
  265. # Complex configuration mocking
  266. ConfigMock
  267. |> expect(:get, fn key ->
  268. [
  269. {Pleroma.Upload, [uploader: Pleroma.Uploaders.S3, base_url: "https://s3.amazonaws.com"]},
  270. {Pleroma.Uploaders.S3, [bucket: "test_bucket"]}
  271. ]
  272. |> get_in(key)
  273. end)
  274. ```
  275. ### Understanding Mox Modes in Pleroma
  276. Pleroma's DataCase automatically configures Mox differently based on whether your test is async or not:
  277. 1. **Async tests** (`async: true`):
  278. - Uses `Mox.set_mox_private()` - expectations are scoped to the current process
  279. - Stubs `Pleroma.CachexMock` with `Pleroma.NullCache`
  280. - Each test process has its own isolated mock expectations
  281. 2. **Non-async tests** (`async: false`):
  282. - Uses `Mox.set_mox_global()` - expectations are shared across processes
  283. - Stubs `Pleroma.CachexMock` with `Pleroma.CachexProxy`
  284. - Mock expectations can be set in one process and called from another
  285. Choose the appropriate mode based on your test requirements. For most tests, async mode is preferred for better performance.
  286. ## Migrating from Mock/meck to Mox
  287. Here's a step-by-step guide for migrating existing tests from Mock/meck to Mox:
  288. ### 1. Identify the Module to Mock
  289. Look for `with_mock` or `test_with_mock` calls in your tests:
  290. ```elixir
  291. # Old approach with Mock
  292. with_mock ExAws, request: fn _ -> {:ok, :ok} end do
  293. assert S3.put_file(file_upload) == {:ok, {:file, "test_folder/image-tet.jpg"}}
  294. end
  295. ```
  296. ### 2. Define a Behavior for the Module
  297. Create a behavior that defines the functions you want to mock:
  298. ```elixir
  299. defmodule Pleroma.Uploaders.S3.ExAwsAPI do
  300. @callback request(op :: ExAws.Operation.t()) :: {:ok, ExAws.Operation.t()} | {:error, term()}
  301. end
  302. ```
  303. ### 3. Update Your Implementation to Use a Configurable Dependency
  304. ```elixir
  305. # Old
  306. def put_file(%Pleroma.Upload{} = upload) do
  307. case ExAws.request(op) do
  308. # ...
  309. end
  310. end
  311. # New
  312. @ex_aws_impl Application.compile_env(:pleroma, [__MODULE__, :ex_aws_impl], ExAws)
  313. def put_file(%Pleroma.Upload{} = upload) do
  314. case @ex_aws_impl.request(op) do
  315. # ...
  316. end
  317. end
  318. ```
  319. ### 4. Define the Mock in mocks.ex
  320. ```elixir
  321. Mox.defmock(Pleroma.Uploaders.S3.ExAwsMock, for: Pleroma.Uploaders.S3.ExAwsAPI)
  322. ```
  323. ### 5. Configure the Test Environment
  324. ```elixir
  325. config :pleroma, Pleroma.Uploaders.S3, ex_aws_impl: Pleroma.Uploaders.S3.ExAwsMock
  326. ```
  327. ### 6. Update Your Tests to Use Mox
  328. ```elixir
  329. # Old (with Mock)
  330. test_with_mock "save file", ExAws, request: fn _ -> {:ok, :ok} end do
  331. assert S3.put_file(file_upload) == {:ok, {:file, "test_folder/image-tet.jpg"}}
  332. assert_called(ExAws.request(:_))
  333. end
  334. # New (with Mox)
  335. test "save file" do
  336. ExAwsMock
  337. |> expect(:request, fn _req -> {:ok, %{status_code: 200}} end)
  338. assert S3.put_file(file_upload) == {:ok, {:file, "test_folder/image-tet.jpg"}}
  339. end
  340. ```
  341. ### 7. Enable Async Testing
  342. Now you can safely enable `async: true` in your test module:
  343. ```elixir
  344. use Pleroma.DataCase, async: true
  345. ```
  346. ## Best Practices
  347. 1. **Always define behaviors**: They serve as contracts and documentation
  348. 2. **Keep mocks in a central location**: Use test/support/mocks.ex for all mock definitions
  349. 3. **Use verify_on_exit!**: This is already set up in DataCase, ensuring all expected calls were made
  350. 4. **Use specific expectations**: Be as specific as possible with your expectations
  351. 5. **Enable async: true**: Take advantage of Mox's concurrent testing capability
  352. 6. **Don't over-mock**: Only mock external dependencies that are difficult to test directly
  353. 7. **Leverage existing stubs**: Use the default stubs provided by DataCase when possible
  354. 8. **Avoid clear_config in async tests**: Use dependency injection and mocking instead
  355. ## Example: Complete Migration
  356. For a complete example of migrating a test from Mock/meck to Mox, you can refer to commit `90a47ca050c5839e8b4dc3bac315dc436d49152d` in the Pleroma repository, which shows how the S3 uploader tests were migrated.
  357. ## Conclusion
  358. Migrating tests from Mock/meck to Mox provides significant benefits for the Pleroma test suite, including faster test execution through async testing, better isolation between tests, and more robust mocking through explicit contracts. By following this guide, you can successfully migrate existing tests and write new tests using Mox.