├── test ├── test_helper.exs └── ex_unit_span │ └── chrome_trace_test.exs ├── assets └── chrome_trace.png ├── .formatter.exs ├── .gitignore ├── README.md ├── .github └── workflows │ └── ci.yml ├── LICENSE.md ├── mix.exs ├── lib ├── ex_unit_span.ex └── ex_unit_span │ ├── chrome_trace.ex │ └── track.ex └── mix.lock /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /assets/chrome_trace.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ananthakumaran/ex_unit_span/HEAD/assets/chrome_trace.png -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | ex_unit_span-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | ex_unit_span.json 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ExUnitSpan 2 | 3 | [![CI](https://github.com/ananthakumaran/ex_unit_span/actions/workflows/ci.yml/badge.svg)](https://github.com/ananthakumaran/ex_unit_span/actions/workflows/ci.yml) 4 | [![Hex.pm](https://img.shields.io/hexpm/v/ex_unit_span.svg)](https://hex.pm/packages/ex_unit_span) 5 | 6 | An ExUnit formatter to visualize test execution and find bottlenecks in your test suite. 7 | 8 | ![TRACE](https://github.com/ananthakumaran/ex_unit_span/raw/master/assets/chrome_trace.png "TRACE") 9 | 10 | ## Installation 11 | 12 | ```elixir 13 | def deps do 14 | [ 15 | {:ex_unit_span, "~> 0.1.0", only: :test} 16 | ] 17 | end 18 | ``` 19 | 20 | ## Usage 21 | 22 | ```bash 23 | mix test --formatter ExUnitSpan 24 | ``` 25 | 26 | This should generate `ex_unit_span.json` file in the current 27 | folder. Open `chrome://tracing` in chrome browser and drop the json 28 | file. 29 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | - push 3 | - pull_request 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | include: 12 | - elixir: 1.8.2 13 | otp: 21.3 14 | - elixir: 1.9.4 15 | otp: 22.2 16 | - elixir: 1.10.4 17 | otp: 23.0 18 | - elixir: 1.12.3 19 | otp: 23.0 20 | - elixir: 1.13.0 21 | otp: 24.1 22 | check_warnings: true 23 | check_format: true 24 | steps: 25 | - uses: actions/checkout@v2 26 | - uses: erlef/setup-beam@v1 27 | with: 28 | otp-version: ${{matrix.otp}} 29 | elixir-version: ${{matrix.elixir}} 30 | - run: mix deps.get 31 | - run: mix format --check-formatted 32 | if: ${{ matrix.check_format }} 33 | - run: mix compile --force --warnings-as-errors 34 | if: ${{ matrix.check_warnings }} 35 | - run: mix test --formatter ExUnitSpan 36 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Anantha Kumaran 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ExUnitSpan.MixProject do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/ananthakumaran/ex_unit_span" 5 | @version "0.1.0" 6 | 7 | def project do 8 | [ 9 | app: :ex_unit_span, 10 | version: @version, 11 | elixir: "~> 1.8", 12 | start_permanent: Mix.env() == :prod, 13 | deps: deps(), 14 | docs: docs(), 15 | package: package() 16 | ] 17 | end 18 | 19 | def application do 20 | [ 21 | extra_applications: [:logger] 22 | ] 23 | end 24 | 25 | defp deps do 26 | [ 27 | {:jason, "~> 1.0"}, 28 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false} 29 | ] 30 | end 31 | 32 | defp docs do 33 | [ 34 | extras: [ 35 | "README.md" 36 | ], 37 | main: "readme", 38 | source_url: @source_url, 39 | source_ref: "v#{@version}", 40 | formatters: ["html"] 41 | ] 42 | end 43 | 44 | defp package do 45 | [ 46 | description: "An ExUnit formatter to visualize test execution", 47 | licenses: ["MIT"], 48 | maintainers: ["ananthakumaran@gmail.com"], 49 | links: %{ 50 | "GitHub" => @source_url 51 | } 52 | ] 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/ex_unit_span/chrome_trace_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExUnitSpan.ChromeTraceTest do 2 | import ExUnitSpan.ChromeTrace 3 | use ExUnit.Case 4 | 5 | test "from_track" do 6 | assert from_track([]) == [] 7 | 8 | assert from_track([ 9 | [%{name: ProcessA, started_at: 1, finished_at: 5, children: []}], 10 | [ 11 | %{ 12 | name: ProcessB, 13 | started_at: 6, 14 | finished_at: 10, 15 | children: [ 16 | %{name: "t1", started_at: 7, finished_at: 9}, 17 | %{name: "t2", started_at: 9, finished_at: 10} 18 | ] 19 | } 20 | ] 21 | ]) == [ 22 | %{name: "ProcessA", ph: "B", pid: 0, tid: 0, ts: 1}, 23 | %{name: "ProcessA", ph: "E", pid: 0, tid: 0, ts: 5}, 24 | %{name: "ProcessB", ph: "B", pid: 1, tid: 0, ts: 6}, 25 | %{name: "ProcessB", ph: "E", pid: 1, tid: 0, ts: 10}, 26 | %{name: "t1", ph: "B", pid: 1, tid: "test", ts: 7}, 27 | %{name: "t1", ph: "E", pid: 1, tid: "test", ts: 9}, 28 | %{name: "t2", ph: "B", pid: 1, tid: "test", ts: 9}, 29 | %{name: "t2", ph: "E", pid: 1, tid: "test", ts: 10} 30 | ] 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/ex_unit_span.ex: -------------------------------------------------------------------------------- 1 | defmodule ExUnitSpan do 2 | alias ExUnitSpan.Track 3 | alias ExUnitSpan.ChromeTrace 4 | 5 | @doc false 6 | use GenServer 7 | 8 | defmodule State do 9 | @moduledoc false 10 | defstruct [:cli_formatter, events: []] 11 | end 12 | 13 | @doc false 14 | def init(opts) do 15 | {:ok, pid} = GenServer.start_link(ExUnit.CLIFormatter, opts) 16 | {:ok, %State{cli_formatter: pid}} 17 | end 18 | 19 | def handle_cast(event = {type, _}, state) when type in [:case_started, :case_finished] do 20 | GenServer.cast(state.cli_formatter, event) 21 | {:noreply, state} 22 | end 23 | 24 | def handle_cast({:suite_finished, run_us, load_us}, state) do 25 | handle_cast({:suite_finished, %{run: run_us, load: load_us, async: 0}}, state) 26 | end 27 | 28 | def handle_cast(event = {type, _}, state) do 29 | timestamp = System.monotonic_time(:microsecond) 30 | state = %{state | events: [{timestamp, event} | state.events]} 31 | GenServer.cast(state.cli_formatter, event) 32 | 33 | if type == :suite_finished do 34 | Enum.reverse(state.events) 35 | |> write_trace_file() 36 | end 37 | 38 | {:noreply, state} 39 | end 40 | 41 | defp write_trace_file(events) do 42 | json = 43 | Track.from_events(events) 44 | |> ChromeTrace.from_track() 45 | |> Jason.encode!() 46 | 47 | File.write!("ex_unit_span.json", json) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/ex_unit_span/chrome_trace.ex: -------------------------------------------------------------------------------- 1 | defmodule ExUnitSpan.ChromeTrace do 2 | @moduledoc false 3 | 4 | # https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/preview 5 | def from_track(lanes) do 6 | Enum.with_index(lanes) 7 | |> Enum.flat_map(fn {lane, pid} -> 8 | Enum.flat_map(lane, fn parent -> 9 | module = 10 | Atom.to_string(parent.name) 11 | |> format_module() 12 | 13 | [ 14 | %{ 15 | name: module, 16 | pid: pid, 17 | tid: 0, 18 | ts: parent.started_at, 19 | ph: "B" 20 | }, 21 | %{ 22 | name: module, 23 | pid: pid, 24 | tid: 0, 25 | ts: parent.finished_at, 26 | ph: "E" 27 | } 28 | | child_trace(parent.children, pid) 29 | ] 30 | end) 31 | end) 32 | end 33 | 34 | defp child_trace(children, pid) do 35 | Enum.flat_map(children, fn child -> 36 | [ 37 | %{ 38 | name: child.name, 39 | pid: pid, 40 | tid: "test", 41 | ts: child.started_at, 42 | ph: "B" 43 | }, 44 | %{ 45 | name: child.name, 46 | pid: pid, 47 | tid: "test", 48 | ts: child.finished_at, 49 | ph: "E" 50 | } 51 | ] 52 | end) 53 | end 54 | 55 | defp format_module("Elixir." <> module), do: module 56 | defp format_module(module), do: module 57 | end 58 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark_parser": {:hex, :earmark_parser, "1.4.19", "de0d033d5ff9fc396a24eadc2fcf2afa3d120841eb3f1004d138cbf9273210e8", [:mix], [], "hexpm", "527ab6630b5c75c3a3960b75844c314ec305c76d9899bb30f71cb85952a9dc45"}, 3 | "ex_doc": {:hex, :ex_doc, "0.26.0", "1922164bac0b18b02f84d6f69cab1b93bc3e870e2ad18d5dacb50a9e06b542a3", [:mix], [{:earmark_parser, "~> 1.4.0", [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", "2775d66e494a9a48355db7867478ffd997864c61c65a47d31c4949459281c78d"}, 4 | "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, 5 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 6 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.2", "dc72dfe17eb240552857465cc00cce390960d9a0c055c4ccd38b70629227e97c", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fd23ae48d09b32eff49d4ced2b43c9f086d402ee4fd4fcb2d7fad97fa8823e75"}, 7 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 8 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.0", "b44d75e2a6542dcb6acf5d71c32c74ca88960421b6874777f79153bbbbd7dccc", [:mix], [], "hexpm", "52b2871a7515a5ac49b00f214e4165a40724cf99798d8e4a65e4fd64ebd002c1"}, 9 | } 10 | -------------------------------------------------------------------------------- /lib/ex_unit_span/track.ex: -------------------------------------------------------------------------------- 1 | defmodule ExUnitSpan.Track do 2 | @moduledoc false 3 | 4 | defstruct [:lanes, :free_lanes, :started_at] 5 | 6 | def from_events(events) do 7 | Enum.reduce(events, %__MODULE__{}, fn event, track -> build(track, event) end) 8 | end 9 | 10 | defp build(%__MODULE__{}, {ts, {:suite_started, _}}) do 11 | %__MODULE__{lanes: %{}, free_lanes: %{}, started_at: ts} 12 | end 13 | 14 | defp build( 15 | %__MODULE__{lanes: lanes, free_lanes: free_lanes}, 16 | {_ts, {:suite_finished, _}} 17 | ) 18 | when map_size(lanes) == 0 do 19 | Enum.to_list(free_lanes) 20 | |> Enum.sort_by(fn {lane_id, _} -> lane_id end) 21 | |> Enum.map(fn {_, lane} -> Enum.reverse(lane) end) 22 | end 23 | 24 | defp build( 25 | %__MODULE__{lanes: lanes, free_lanes: free_lanes} = track, 26 | {ts, {:module_started, test_module}} 27 | ) do 28 | parent = %{name: test_module.name, children: [], started_at: ts - track.started_at} 29 | 30 | if Enum.empty?(free_lanes) do 31 | lanes = Map.put(lanes, map_size(lanes), [parent]) 32 | %{track | lanes: lanes} 33 | else 34 | lane_id = Enum.min(Map.keys(free_lanes)) 35 | {lane, free_lanes} = Map.pop(free_lanes, lane_id) 36 | lanes = Map.put(lanes, lane_id, [parent | lane]) 37 | %{track | lanes: lanes, free_lanes: free_lanes} 38 | end 39 | end 40 | 41 | defp build( 42 | %__MODULE__{lanes: lanes, free_lanes: free_lanes} = track, 43 | {ts, {:module_finished, test_module}} 44 | ) do 45 | {lane_id, lane} = 46 | Enum.find(lanes, fn {_, [parent | _]} -> parent.name == test_module.name end) 47 | 48 | [parent | rest] = lane 49 | 50 | parent = 51 | Map.put(parent, :finished_at, ts - track.started_at) 52 | |> Map.update!(:children, &Enum.reverse/1) 53 | 54 | free_lanes = Map.put(free_lanes, lane_id, [parent | rest]) 55 | {_, lanes} = Map.pop(lanes, lane_id) 56 | %{track | lanes: lanes, free_lanes: free_lanes} 57 | end 58 | 59 | defp build( 60 | %__MODULE__{lanes: lanes} = track, 61 | {ts, {:test_started, test}} 62 | ) do 63 | {lane_id, [parent | rest]} = 64 | Enum.find(lanes, fn {_, [parent | _]} -> parent.name == test.module end) 65 | 66 | child = %{name: test.name, started_at: ts - track.started_at} 67 | parent = %{parent | children: [child | parent.children]} 68 | lanes = Map.put(lanes, lane_id, [parent | rest]) 69 | %{track | lanes: lanes} 70 | end 71 | 72 | defp build( 73 | %__MODULE__{lanes: lanes} = track, 74 | {ts, {:test_finished, test}} 75 | ) do 76 | {lane_id, [parent | rest]} = 77 | Enum.find(lanes, fn {_, [parent | _]} -> parent.name == test.module end) 78 | 79 | [child | rest_children] = parent.children 80 | child = Map.put(child, :finished_at, ts - track.started_at) 81 | parent = %{parent | children: [child | rest_children]} 82 | lanes = Map.put(lanes, lane_id, [parent | rest]) 83 | %{track | lanes: lanes} 84 | end 85 | end 86 | --------------------------------------------------------------------------------