Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ The type system was made possible thanks to a partnership between [CNRS](https:/
* [Kernel] Warn on unused requires
* [Regex] Add `Regex.import/1` to import regexes defined with `/E`

#### ExUnit

* [ExUnit.CaptureLog] Add `:formatter` option for custom log formatting

#### Mix

* [mix test] Add `mix test --dry-run`
Expand Down
14 changes: 10 additions & 4 deletions lib/ex_unit/lib/ex_unit/capture_log.ex
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,17 @@ defmodule ExUnit.CaptureLog do
@compile {:no_warn_undefined, Logger}

@type capture_log_opts :: [
level: Logger.level() | nil
{:level, Logger.level() | nil}
| {:formatter, {module(), term()} | nil}
]

@doc """
Captures Logger messages generated when evaluating `fun`.

Returns the binary which is the captured output. The captured log
messages will be formatted using `Logger.default_formatter/1`. Any
option, besides the `:level`, will be forwarded as an override to
the default formatter.
messages will be formatted using `Logger.default_formatter/1` by
default. Any option, besides `:level` or `formatter`, will be
forwarded as an override to the default formatter.

This function mutes the default logger handler and captures any log
messages sent to Logger from the calling processes. It is possible
Expand All @@ -75,6 +76,11 @@ defmodule ExUnit.CaptureLog do
configured in this function, no message will be captured.
The behaviour is undetermined if async tests change Logger level.

It is possible to use an alternative log formatter with `:formatter`,
which must be provided as `{module, config}`. If `:formatter` is not
provided, the formatter configuration from `Logger.default_formatter/1`
will be used.

To get the result of the evaluation along with the captured log,
use `with_log/2`.
"""
Expand Down
5 changes: 4 additions & 1 deletion lib/ex_unit/lib/ex_unit/capture_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ defmodule ExUnit.CaptureServer do
refs = Map.put(config.log_captures, ref, true)

{level, opts} = Keyword.pop(opts, :level)
{formatter_mod, formatter_config} = Logger.default_formatter(opts)

{formatter_mod, formatter_config} =
Keyword.get_lazy(opts, :formatter, fn -> Logger.default_formatter(opts) end)
Copy link
Contributor

@sabiwara sabiwara Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the spec, formatter can be {module(), term()} | nil, but wouldn't this fail in the nil case?

Should it be Keyword.get(opts, :formatter) || Logger.default_formatter(opts), or should we fix the spec?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. I can make either change.


true = :ets.insert(@ets, {ref, string_io, level || :all, formatter_mod, formatter_config})

if map_size(refs) == 1 do
Expand Down
38 changes: 38 additions & 0 deletions lib/ex_unit/test/ex_unit/capture_log_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,44 @@ defmodule ExUnit.CaptureLogTest do
end
end

defmodule CustomFormatter do
def new do
{_, opts} =
Logger.Formatter.new(
format: "$metadata| $message",
metadata: [:id],
colors: [enabled: false]
)

{__MODULE__, opts}
end

def format(event, config) do
event
|> wrap()
|> Logger.Formatter.format(config)
end

defp wrap(event) do
msg =
event
|> Logger.Formatter.format_event(:infinity)
|> to_string()

%{event | msg: {:string, ["[CUSTOM]", msg, "[/CUSTOM]"]}}
end
end

test "uses the formatter from the `:formatter` option" do
log =
capture_log([formatter: CustomFormatter.new()], fn ->
Logger.info("hello", id: 123)
2 + 2
end)

assert log == "id=123 | [CUSTOM]hello[/CUSTOM]"
end

defp wait_capture_removal() do
if ExUnit.CaptureServer in Enum.map(:logger.get_handler_config(), & &1.id) do
Process.sleep(20)
Expand Down
Loading