├── .formatter.exs ├── .github └── workflows │ └── elixir.yml ├── .gitignore ├── .tool-versions ├── LICENSE ├── README.md ├── example_failing_project ├── .gitignore ├── lib │ └── example_failing_project.ex ├── mix.exs └── test │ ├── example_failing_project_test.exs │ └── test_helper.exs ├── example_project ├── .gitignore ├── lib │ ├── example_project.ex │ └── example_project │ │ ├── example_behaviour.ex │ │ └── example_module.ex ├── mix.exs └── test │ ├── example_project_test.exs │ ├── support │ └── mocks.ex │ └── test_helper.exs ├── example_umbrella_project ├── .formatter.exs ├── .gitignore ├── apps │ ├── example_project │ │ ├── .gitignore │ │ ├── lib │ │ │ ├── example_project.ex │ │ │ └── example_project │ │ │ │ └── example_module.ex │ │ ├── mix.exs │ │ └── test │ │ │ ├── example_project_test.exs │ │ │ └── test_helper.exs │ └── example_project_2 │ │ ├── .gitignore │ │ ├── lib │ │ ├── example_project_2.ex │ │ └── example_project_2 │ │ │ └── example_module.ex │ │ ├── mix.exs │ │ └── test │ │ ├── example_project_2_test.exs │ │ └── test_helper.exs ├── config │ └── config.exs └── mix.exs ├── lib ├── lcov_ex.ex ├── lcov_ex │ ├── formatter.ex │ └── stats.ex └── tasks │ ├── lcov.ex │ ├── lcov.run.ex │ └── lcov │ └── load_and_run_task.exs ├── mix.exs ├── mix.lock └── test ├── lcov_ex ├── formatter_test.exs └── stats_test.exs ├── lcov_ex_test.exs ├── support └── mix_file_helper.ex ├── tasks └── lcov_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | name: Build and test 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Set up Elixir 18 | uses: erlef/setup-beam@v1 19 | with: 20 | elixir-version: '1.14' # Define the elixir version [required] 21 | otp-version: '25.1' # Define the OTP version [required] 22 | - name: Restore dependencies cache 23 | uses: actions/cache@v3 24 | with: 25 | path: deps 26 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 27 | restore-keys: ${{ runner.os }}-mix- 28 | - name: Install dependencies 29 | run: mix deps.get 30 | - name: Run tests with lcov --exit --fail-fast 31 | run: mix lcov --exit --fail-fast 32 | - name: Upload code coverage 33 | uses: actions/upload-artifact@v4 34 | with: 35 | name: lcov-file 36 | path: cover/lcov.info 37 | 38 | coverage_report: 39 | name: Generate coverage report 40 | needs: [build] 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: Download code coverage reports 44 | uses: actions/download-artifact@v4 45 | with: 46 | name: lcov-file 47 | path: cover 48 | - name: Report code coverage 49 | uses: kefasjw/lcov-pull-request-report@v1 50 | with: 51 | # Lcov file location 52 | lcov-file: cover/lcov.info 53 | # Github token required for getting list of changed files and posting comments 54 | github-token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.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 | lcov_ex-*.tar 24 | 25 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.14 2 | erlang 25.1 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 dariodf 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lcov_ex 2 | 3 | Test coverage module to generate a `lcov.info` file for an Elixir project. 4 | 5 | The docs can be found at [https://hexdocs.pm/lcov_ex](https://hexdocs.pm/lcov_ex). 6 | 7 | ## Why 8 | 9 | ### Visualize line coverage 10 | 11 | Many test coverage tools use [`lcov`](https://manpages.debian.org/stretch/lcov/geninfo.1.en.html#FILES) files as an input. 12 | 13 | You can use it as I do to watch coverage progress in the following editors: 14 | 15 | - VSCode: 16 | - Using the [Coverage Gutters](https://github.com/ryanluker/vscode-coverage-gutters) extension. 17 | - Using the [Koverage](https://marketplace.visualstudio.com/items?itemName=tenninebt.vscode-koverage) extension (add "cover" in settings as coverage folder, or output the report to the "coverage" folder, see below). 18 | - Atom, using the [lcov-info](https://atom.io/packages/lcov-info) extension (it requires you to change the output folder to "coverage", see below). 19 | 20 | Please let me know if you made it work in your previously unlisted favorite editor. Or, if you're really nice, just add it to this list yourself :slightly_smiling_face: 21 | 22 | ### Coverage report in CI 23 | 24 | You can use `mix lcov --exit` in a Github Action CI to safely run your tests and generate the lcov file, and then use that with [a report tool](https://github.com/marketplace/actions/lcov-pull-request-report) to generate a comment with code coverage information in your pull requests. 25 | 26 | See [this project's CI configuration](https://github.com/dariodf/lcov_ex/blob/master/.github/workflows/elixir.yml) for more info on how to set it up. 27 | 28 | ## Installation 29 | 30 | Add to your dependencies: 31 | 32 | ```elixir 33 | def deps do 34 | [ 35 | {:lcov_ex, "~> 0.3", only: [:dev, :test], runtime: false} 36 | ] 37 | end 38 | ``` 39 | 40 | ## Usage 41 | 42 | ```shell 43 | mix lcov 44 | ``` 45 | 46 | File should be created at `./cover/lcov.info` by default. 47 | 48 | ### Options 49 | 50 | #### `--quiet` 51 | 52 | To run silently use the `--quiet` option: 53 | 54 | ```shell 55 | mix lcov --quiet 56 | ``` 57 | 58 | #### `--output ` 59 | 60 | To output the file to a different folder, use the `--output` option: 61 | 62 | ```shell 63 | mix lcov --output coverage 64 | ... 65 | Coverage file successfully created at coverage/lcov.info 66 | ``` 67 | 68 | #### `--exit` 69 | 70 | Exits with a non-zero exit code if the tests fail: the same code that `mix test` would have exited with. 71 | 72 | ``` shell 73 | mix lcov --exit 74 | ``` 75 | 76 | #### `--fail-fast` 77 | 78 | Fails the task early at the first failed test by passing the `--max-failures 1` option to `mix test`. 79 | 80 | ``` shell 81 | mix lcov --fail-fast 82 | ``` 83 | 84 | Useful in combination with `--exit` for CI. 85 | 86 | ``` shell 87 | mix lcov --fail-fast --exit 88 | ``` 89 | 90 | ### Umbrella projects 91 | 92 | By default, running `mix lcov` at the umbrella level will generate the coverage report for all individual apps and then compile them into a single file at `./cover/lcov.info`. 93 | 94 | #### `--keep` 95 | 96 | For umbrella projects you can choose to keep the individual apps lcov files with the `--keep` option: 97 | 98 | ```shell 99 | mix lcov --keep 100 | ... 101 | Coverage file for my_app created at apps/my_app/cover/lcov.info 102 | Coverage file for my_other_app created at apps/my_other_app/cover/lcov.info 103 | 104 | Coverage file for umbrella created at cover/lcov.info 105 | ``` 106 | 107 | #### Run for single umbrella app 108 | 109 | You can choose to run `mix lcov` for any single app inside an umbrella project by passing its folder as an argument. 110 | 111 | ```shell 112 | mix lcov /apps/myapp 113 | ``` 114 | 115 | File should be created at `./apps/my_app/cover/lcov.info` by default. 116 | 117 | ### As test coverage tool 118 | 119 | Alternatively, you can set up `LcovEx` as your test coverage tool in your project configuration: 120 | 121 | ```elixir 122 | def project do 123 | [ 124 | ... 125 | test_coverage: [tool: LcovEx, output: "cover"], 126 | ... 127 | ] 128 | ``` 129 | 130 | And then, run with: 131 | 132 | ```shell 133 | mix test --cover 134 | ``` 135 | 136 | The `output` option indicates the output folder for the generated file. 137 | 138 | Optionally, the `ignore_paths` option can be a list of path prefixes to ignore when generating the coverage report. 139 | 140 | ```elixir 141 | def project do 142 | [ 143 | ... 144 | test_coverage: [tool: LcovEx, output: "cover", ignore_paths: ["test/", "deps/"]] 145 | ... 146 | ] 147 | ``` 148 | 149 | ## TODOs 150 | 151 | - Add missing `FN` lines, for the sake of completion. 152 | -------------------------------------------------------------------------------- /example_failing_project/.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 | lcov_ex-*.tar 24 | 25 | -------------------------------------------------------------------------------- /example_failing_project/lib/example_failing_project.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleFailingProject do 2 | @moduledoc false 3 | end 4 | -------------------------------------------------------------------------------- /example_failing_project/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ExampleProject.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :example_failing_project, 7 | version: "0.1.0", 8 | elixir: "~> 1.9", 9 | elixirc_paths: ["lib"], 10 | start_permanent: Mix.env() == :prod, 11 | deps: deps() 12 | ] 13 | end 14 | 15 | def application do 16 | [ 17 | extra_applications: [:logger] 18 | ] 19 | end 20 | 21 | defp deps do 22 | [ 23 | {:lcov_ex, path: "../", only: [:dev, :test]} 24 | ] 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /example_failing_project/test/example_failing_project_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExampleFailingProjectTest do 2 | use ExUnit.Case 3 | 4 | test "failure" do 5 | assert false 6 | end 7 | 8 | test "second failure" do 9 | assert false 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /example_failing_project/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /example_project/.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 | lcov_ex-*.tar 24 | 25 | -------------------------------------------------------------------------------- /example_project/lib/example_project.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleProject do 2 | @moduledoc false 3 | 4 | def covered() do 5 | ExampleProject.ExampleModule.cover() 6 | end 7 | 8 | def mocked(module) do 9 | ExampleProject.ExampleBehaviour.call(module) 10 | end 11 | 12 | def not_covered() do 13 | ExampleProject.ExampleModule.cover() 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /example_project/lib/example_project/example_behaviour.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleProject.ExampleBehaviour do 2 | @moduledoc false 3 | @callback callback() :: any() 4 | 5 | @doc false 6 | def call(module), do: module.callback() 7 | end 8 | -------------------------------------------------------------------------------- /example_project/lib/example_project/example_module.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleProject.ExampleModule do 2 | @moduledoc false 3 | 4 | def cover() do 5 | get_value() 6 | end 7 | 8 | defp get_value() do 9 | :covered 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /example_project/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ExampleProject.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :example_project, 7 | version: "0.1.0", 8 | elixir: "~> 1.9", 9 | elixirc_paths: elixirc_paths(Mix.env), 10 | start_permanent: Mix.env() == :prod, 11 | deps: deps() 12 | ] 13 | end 14 | 15 | def application do 16 | [ 17 | extra_applications: [:logger] 18 | ] 19 | end 20 | 21 | defp elixirc_paths(:test), do: ["test/support", "lib"] 22 | defp elixirc_paths(_), do: ["lib"] 23 | 24 | defp deps do 25 | [ 26 | {:lcov_ex, path: "../", only: [:dev, :test]}, 27 | {:mox, path: "../deps/mox", only: [:dev, :test]} 28 | ] 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /example_project/test/example_project_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExampleProjectTest do 2 | use ExUnit.Case 3 | doctest ExampleProject 4 | import Mox 5 | 6 | test "run covered function" do 7 | assert ExampleProject.covered() == :covered 8 | end 9 | 10 | test "run mox" do 11 | expect(ExampleProject.ExampleBehaviour.Mox, :callback, fn -> :ok end) 12 | assert :ok == ExampleProject.mocked(ExampleProject.ExampleBehaviour.Mox) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /example_project/test/support/mocks.ex: -------------------------------------------------------------------------------- 1 | Mox.defmock(ExampleProject.ExampleBehaviour.Mox, for: ExampleProject.ExampleBehaviour) 2 | -------------------------------------------------------------------------------- /example_project/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /example_umbrella_project/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "config/*.exs"], 4 | subdirectories: ["apps/*"] 5 | ] 6 | -------------------------------------------------------------------------------- /example_umbrella_project/.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 | # Temporary files, for example, from tests. 23 | /tmp/ 24 | -------------------------------------------------------------------------------- /example_umbrella_project/apps/example_project/.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 | lcov_ex-*.tar 24 | 25 | -------------------------------------------------------------------------------- /example_umbrella_project/apps/example_project/lib/example_project.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleProject do 2 | @moduledoc false 3 | 4 | def covered() do 5 | ExampleProject.ExampleModule.cover() 6 | end 7 | 8 | def not_covered() do 9 | ExampleProject.ExampleModule.cover() 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /example_umbrella_project/apps/example_project/lib/example_project/example_module.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleProject.ExampleModule do 2 | @moduledoc false 3 | 4 | def cover() do 5 | get_value() 6 | end 7 | 8 | defp get_value() do 9 | :covered 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /example_umbrella_project/apps/example_project/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ExampleProject.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :example_project, 7 | version: "0.1.0", 8 | elixir: "~> 1.9", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | def application do 15 | [ 16 | extra_applications: [:logger] 17 | ] 18 | end 19 | 20 | defp deps do 21 | [{:lcov_ex, path: "../../../", only: [:dev, :test]}] 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /example_umbrella_project/apps/example_project/test/example_project_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExampleProjectTest do 2 | use ExUnit.Case 3 | doctest ExampleProject 4 | 5 | test "run covered function" do 6 | assert ExampleProject.covered() == :covered 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /example_umbrella_project/apps/example_project/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /example_umbrella_project/apps/example_project_2/.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 | lcov_ex-*.tar 24 | 25 | -------------------------------------------------------------------------------- /example_umbrella_project/apps/example_project_2/lib/example_project_2.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleProject2 do 2 | @moduledoc false 3 | 4 | def covered() do 5 | ExampleProject2.ExampleModule.cover() 6 | end 7 | 8 | def also_covered() do 9 | ExampleProject2.ExampleModule.cover() 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /example_umbrella_project/apps/example_project_2/lib/example_project_2/example_module.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleProject2.ExampleModule do 2 | @moduledoc false 3 | 4 | def cover() do 5 | get_value() 6 | end 7 | 8 | defp get_value() do 9 | :covered 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /example_umbrella_project/apps/example_project_2/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ExampleProject2.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :example_project_2, 7 | version: "0.1.0", 8 | elixir: "~> 1.9", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | def application do 15 | [ 16 | extra_applications: [:logger] 17 | ] 18 | end 19 | 20 | defp deps do 21 | [] 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /example_umbrella_project/apps/example_project_2/test/example_project_2_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExampleProject2Test do 2 | use ExUnit.Case 3 | doctest ExampleProject2 4 | 5 | test "run covered function" do 6 | assert ExampleProject2.covered() == :covered 7 | end 8 | 9 | test "run also covered function" do 10 | assert ExampleProject2.also_covered() == :covered 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /example_umbrella_project/apps/example_project_2/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /example_umbrella_project/config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your umbrella 2 | # and **all applications** and their dependencies with the 3 | # help of the Config module. 4 | # 5 | # Note that all applications in your umbrella share the 6 | # same configuration and dependencies, which is why they 7 | # all use the same configuration file. If you want different 8 | # configurations or dependencies per app, it is best to 9 | # move said applications out of the umbrella. 10 | import Config 11 | 12 | # Sample configuration: 13 | # 14 | # config :logger, :console, 15 | # level: :info, 16 | # format: "$date $time [$level] $metadata$message\n", 17 | # metadata: [:user_id] 18 | # 19 | -------------------------------------------------------------------------------- /example_umbrella_project/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ExampleUmbrellaProject.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | apps_path: "apps", 7 | version: "0.1.0", 8 | start_permanent: Mix.env() == :prod, 9 | deps: deps() 10 | ] 11 | end 12 | 13 | # Dependencies listed here are available only for this 14 | # project and cannot be accessed from applications inside 15 | # the apps folder. 16 | # 17 | # Run "mix help deps" for examples and options. 18 | defp deps do 19 | [] 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/lcov_ex.ex: -------------------------------------------------------------------------------- 1 | defmodule LcovEx do 2 | @moduledoc """ 3 | Lcov file generator for Elixir projects. 4 | 5 | Go to https://github.com/dariodf/lcov_ex for installation and usage instructions. 6 | """ 7 | 8 | alias LcovEx.{Formatter, Stats} 9 | 10 | def start(compile_path, opts) do 11 | log_info("Compiling coverage... ") 12 | :cover.start() 13 | 14 | case :cover.compile_beam_directory(compile_path |> to_charlist) do 15 | results when is_list(results) -> 16 | :ok 17 | 18 | {:error, _} -> 19 | Mix.raise("Failed to compile coverage for directory: " <> compile_path) 20 | end 21 | 22 | output = opts[:output] 23 | caller_cwd = opts[:cwd] || File.cwd!() 24 | ignored_paths = Keyword.get(opts, :ignore_paths, []) 25 | 26 | fn -> 27 | log_info("\nGenerating lcov file...") 28 | 29 | lcov = 30 | :cover.modules() 31 | |> Enum.sort() 32 | |> Enum.map(&calculate_module_coverage(&1, ignored_paths, caller_cwd)) 33 | 34 | File.mkdir_p!(output) 35 | path = "#{output}/lcov.info" 36 | File.write!(path, lcov, [:write]) 37 | 38 | inform_file_written(opts) 39 | 40 | :cover.stop() 41 | end 42 | end 43 | 44 | defp calculate_module_coverage(mod, ignored_paths, cwd) do 45 | path = mod.module_info(:compile)[:source] |> to_string() |> Path.relative_to(cwd) 46 | 47 | # Ignore compiled modules with path: 48 | # - not relative to the app (e.g. generated by umbrella dependencies) 49 | # - ignored by configuration 50 | if Path.type(path) != :relative or Enum.any?(ignored_paths, &String.starts_with?(path, &1)) do 51 | [] 52 | else 53 | calculate_and_format_coverage(mod, path) 54 | end 55 | end 56 | 57 | defp calculate_and_format_coverage(mod, path) do 58 | {:ok, fun_data} = :cover.analyse(mod, :calls, :function) 59 | {functions_coverage, %{fnf: fnf, fnh: fnh}} = Stats.function_coverage_data(fun_data) 60 | 61 | {:ok, lines_data} = :cover.analyse(mod, :calls, :line) 62 | {lines_coverage, %{lf: lf, lh: lh}} = Stats.line_coverage_data(lines_data) 63 | 64 | Formatter.format_lcov(mod, path, functions_coverage, fnf, fnh, lines_coverage, lf, lh) 65 | end 66 | 67 | defp inform_file_written(opts) do 68 | output = opts[:output] 69 | caller_cwd = opts[:cwd] || File.cwd!() 70 | path = "#{output}/lcov.info" 71 | keep? = opts[:keep] || false 72 | recursing? = Mix.Task.recursing?() 73 | app_path = opts[:app_path] 74 | app = Mix.Project.config()[:app] 75 | app_lcov_path = File.cwd!() |> Path.relative_to(caller_cwd) |> Path.join(path) 76 | 77 | cond do 78 | recursing? && keep? -> 79 | # Using --keep option from umbrella 80 | log_info("\nCoverage file for #{app} created at #{app_lcov_path}") 81 | 82 | app_path && File.cwd!() != caller_cwd -> 83 | # Using an umbrella app path from umbrella 84 | log_info("\nCoverage file created at #{app_lcov_path}") 85 | 86 | File.cwd!() == caller_cwd -> 87 | # Not an umbrella 88 | log_info("\nCoverage file created at #{path}") 89 | 90 | true -> 91 | # Don't log for umbrellas unless using --keep 92 | :no_log 93 | end 94 | end 95 | 96 | defp log_info(msg) do 97 | Mix.shell().info(msg) 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/lcov_ex/formatter.ex: -------------------------------------------------------------------------------- 1 | defmodule LcovEx.Formatter do 2 | @moduledoc """ 3 | Formatter for `lcov.info` file. 4 | 5 | See more information about lcov in https://manpages.debian.org/stretch/lcov/geninfo.1.en.html#FILES. 6 | """ 7 | 8 | @type mod :: module() 9 | @type path :: binary() 10 | @type coverage_info :: {binary(), integer()} 11 | 12 | @newline "\n" 13 | 14 | @doc """ 15 | Create a lcov specification for a module. 16 | """ 17 | @spec format_lcov( 18 | mod(), 19 | path(), 20 | [coverage_info(), ...], 21 | integer(), 22 | integer(), 23 | [coverage_info(), ...], 24 | integer(), 25 | integer() 26 | ) :: [binary()] 27 | def format_lcov(mod, path, functions_coverage, fnf, fnh, lines_coverage, lf, lh) do 28 | # TODO FN 29 | [ 30 | "TN:", 31 | Atom.to_string(mod), 32 | @newline, 33 | "SF:", 34 | path, 35 | @newline, 36 | fnda(functions_coverage), 37 | "FNF:", 38 | Integer.to_string(fnf), 39 | @newline, 40 | "FNH:", 41 | Integer.to_string(fnh), 42 | @newline, 43 | da(lines_coverage), 44 | "LF:", 45 | Integer.to_string(lf), 46 | @newline, 47 | "LH:", 48 | Integer.to_string(lh), 49 | @newline, 50 | "end_of_record", 51 | @newline 52 | ] 53 | end 54 | 55 | # FNDA:, 56 | defp fnda(functions_coverage) do 57 | Enum.map(functions_coverage, fn {function_name, execution_count} -> 58 | ["FNDA:", Integer.to_string(execution_count), ?,, function_name, @newline] 59 | end) 60 | end 61 | 62 | # DA:,[,] 63 | defp da(lines_coverage) do 64 | Enum.map(lines_coverage, fn {line_number, execution_count} -> 65 | ["DA:", Integer.to_string(line_number), ?,, Integer.to_string(execution_count), @newline] 66 | end) 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/lcov_ex/stats.ex: -------------------------------------------------------------------------------- 1 | defmodule LcovEx.Stats do 2 | @moduledoc """ 3 | Output parser for `:cover.analyse/3` 4 | """ 5 | @type cover_analyze_function_output :: [{{module(), atom(), integer()}, integer()}, ...] 6 | @type cover_analyze_line_output :: [{{module(), integer()}, integer()}, ...] 7 | @type coverage_info :: {binary(), integer()} 8 | 9 | @doc """ 10 | Function coverage data parser. Discards BEAM file `:__info__/1` function data. 11 | 12 | ## Examples 13 | 14 | iex> LcovEx.Stats.function_coverage_data([{{MyModule, :__info__, 1}, 3}, {{MyModule, :foo, 2}, 0}]) 15 | {[{"foo/2", 0}], %{fnf: 1, fnh: 0}} 16 | 17 | """ 18 | @spec function_coverage_data(cover_analyze_function_output()) :: 19 | {[coverage_info(), ...], %{fnf: integer(), fnh: integer()}} 20 | def function_coverage_data(fun_data) do 21 | Enum.reduce_while(fun_data, {[], %{fnf: 0, fnh: 0}}, fn data, 22 | acc = {list, %{fnf: fnf, fnh: fnh}} -> 23 | # TODO get FN + line by inspecting file 24 | case data do 25 | {{_, :__info__, _1}, _} -> 26 | {:cont, acc} 27 | 28 | {{_mod, name, arity}, count} -> 29 | {:cont, 30 | {list ++ [{"#{name}/#{arity}", count}], 31 | %{fnf: fnf + 1, fnh: fnh + ((count > 0 && 1) || 0)}}} 32 | end 33 | end) 34 | end 35 | 36 | @doc """ 37 | Function coverage data parser. Discards BEAM file line `0` data. 38 | 39 | ## Examples 40 | 41 | iex> LcovEx.Stats.line_coverage_data([{{MyModule, 0}, 3}, {{MyModule, 0}, 0}, {{MyModule, 8}, 0}]) 42 | {[{8, 0}], %{lf: 1, lh: 0}} 43 | 44 | iex> LcovEx.Stats.line_coverage_data([{{MyModule, 1}, 12}, {{MyModule, 1}, 0}, {{MyModule, 2}, 0}]) 45 | {[{1, 12}, {2, 0}], %{lf: 2, lh: 1}} 46 | 47 | """ 48 | @spec line_coverage_data(cover_analyze_line_output()) :: 49 | {[coverage_info(), ...], %{lf: integer(), lh: integer()}} 50 | def line_coverage_data(lines_data) do 51 | {list_reversed, _previous_line, lf, lh} = 52 | Enum.reduce(lines_data, {[], nil, 0, 0}, fn data, acc = {list, previous_line, lf, lh} -> 53 | case data do 54 | {{_, 0}, _} -> 55 | acc 56 | 57 | {^previous_line, count} -> 58 | [{line, previous_count} | rest] = list 59 | count = max(count, previous_count) 60 | 61 | lh = increment_line_hit(lh, count, previous_count) 62 | 63 | {[{line, count} | rest], previous_line, lf, lh} 64 | 65 | {{_mod, line} = previous_line, count} -> 66 | list = [{line, count} | list] 67 | lf = lf + 1 68 | lh = increment_line_hit(lh, count, 0) 69 | {list, previous_line, lf, lh} 70 | end 71 | end) 72 | 73 | {Enum.reverse(list_reversed), %{lf: lf, lh: lh}} 74 | end 75 | 76 | defp increment_line_hit(lh, count, previous_count) 77 | defp increment_line_hit(lh, 0, _), do: lh 78 | defp increment_line_hit(lh, _count, 0), do: lh + 1 79 | defp increment_line_hit(lh, _, _), do: lh 80 | end 81 | -------------------------------------------------------------------------------- /lib/tasks/lcov.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Lcov do 2 | @moduledoc "Generates lcov test coverage files for the application" 3 | @shortdoc "Generates lcov files" 4 | @load_and_run_task_script File.read!("./lib/tasks/lcov/load_and_run_task.exs") 5 | 6 | use Mix.Task 7 | require Logger 8 | 9 | @doc """ 10 | Generates the `lcov.info` file. 11 | """ 12 | @impl Mix.Task 13 | def run(args) do 14 | {opts, files} = 15 | OptionParser.parse!(args, 16 | strict: [quiet: :boolean, keep: :boolean, output: :string, exit: :boolean, fail_fast: :boolean] 17 | ) 18 | 19 | if opts[:quiet], do: Mix.shell(Mix.Shell.Quiet) 20 | 21 | cwd = File.cwd!() 22 | path = Enum.at(files, 0) || cwd 23 | 24 | # Actually run tests and coverage 25 | task = "lcov.run" 26 | args = Enum.join(args ++ ["--cwd #{cwd}"], " ") 27 | 28 | # Script to load a mix task and related dependency modules from beam files on runtime if necessary, 29 | # and then run the task 30 | script = @load_and_run_task_script 31 | 32 | task_module = Mix.Task.get(task) 33 | # .beam path for `lcov.run` task 34 | beam_path = task_module |> :code.which() |> to_string() 35 | 36 | test_exit_code = 37 | Mix.shell().cmd( 38 | """ 39 | mix run -e "#{script}" "#{beam_path}" "#{task_module}" "#{task} #{args}" 40 | """, 41 | env: [{"MIX_ENV", "test"}] 42 | ) 43 | 44 | # --exit option makes the task exit with the same exit code as the tests 45 | if opts[:exit] && test_exit_code != 0, 46 | do: System.at_exit(fn _ -> exit({:shutdown, test_exit_code}) end) 47 | 48 | # Umbrella projects support 49 | if Mix.Project.umbrella?() && path == cwd, do: umbrella_support(opts) 50 | :ok 51 | end 52 | 53 | defp umbrella_support(opts) do 54 | # Setup folder, reset file 55 | output = opts[:output] || "cover" 56 | file_path = "#{output}/lcov.info" 57 | File.mkdir_p!(output) 58 | File.rm(file_path) 59 | 60 | # Append apps coverage to a single umbrella coverage file 61 | for {_app, path} <- Mix.Project.apps_paths() do 62 | app_lcov_path = Path.join(path, file_path) 63 | app_lcov = app_lcov_path |> File.read!() 64 | 65 | File.write!(file_path, app_lcov, [:append]) 66 | 67 | # Remove unless --keep 68 | unless opts[:keep] do 69 | File.rm!(app_lcov_path) 70 | end 71 | end 72 | 73 | log_info("\nCoverage file for umbrella created at #{file_path}", opts) 74 | 75 | :ok 76 | end 77 | 78 | defp log_info(msg, opts) do 79 | unless Keyword.get(opts, :quiet, false) do 80 | Mix.shell().info(msg) 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/tasks/lcov.run.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Lcov.Run do 2 | @moduledoc "Generates lcov test coverage files for the application" 3 | @shortdoc "Generates lcov files" 4 | @recursive true 5 | @preferred_cli_env :test 6 | 7 | # Ignore modules compiled by dependencies 8 | @ignored_paths ["deps/"] 9 | 10 | use Mix.Task 11 | require Logger 12 | 13 | @doc """ 14 | Generates the `lcov.info` file. 15 | """ 16 | @impl Mix.Task 17 | def run(args) do 18 | {opts, files} = 19 | OptionParser.parse!(args, 20 | strict: [ 21 | quiet: :boolean, 22 | keep: :boolean, 23 | output: :string, 24 | exit: :boolean, 25 | fail_fast: :boolean, 26 | cwd: :string 27 | ] 28 | ) 29 | 30 | if opts[:quiet], do: Mix.shell(Mix.Shell.Quiet) 31 | 32 | # lcov.info file setup 33 | output = opts[:output] || "cover" 34 | file_path = "#{output}/lcov.info" 35 | File.mkdir_p!(output) 36 | File.rm(file_path) 37 | 38 | app_path = Enum.at(files, 0) 39 | 40 | # Update config for current project on runtime 41 | config = [ 42 | test_coverage: [ 43 | tool: LcovEx, 44 | output: output, 45 | ignore_paths: @ignored_paths, 46 | cwd: opts[:cwd], 47 | keep: opts[:keep], 48 | app_path: app_path 49 | ] 50 | ] 51 | 52 | mix_path = Mix.Project.project_file() 53 | new_config = Mix.Project.config() |> Keyword.merge(config) 54 | project = Mix.Project.get() 55 | Mix.ProjectStack.pop() 56 | Mix.ProjectStack.push(project, new_config, mix_path) 57 | 58 | test_params = 59 | ["--cover", "--color"] ++ 60 | if(app_path, do: [Path.join("#{app_path}", "test")], else: []) ++ 61 | if(opts[:fail_fast], do: ["--max-failures", "1"], else: []) 62 | 63 | # Run tests with updated :test_coverage configuration 64 | Mix.Task.run("test", test_params) 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/tasks/lcov/load_and_run_task.exs: -------------------------------------------------------------------------------- 1 | # Script to load a mix task and related dependency modules from beam files on runtime if necessary, 2 | # and then run the task 3 | beam_path = System.argv() |> Enum.at(0) 4 | task_module = System.argv() |> Enum.at(1) |> String.to_atom() 5 | 6 | # Ensure that task module is loaded 7 | unless Code.ensure_loaded?(task_module) do 8 | # Get dependency beam files data 9 | beam_dir = Path.dirname(beam_path) 10 | beam_extension = Path.extname(beam_path) 11 | # Load all dependency modules 12 | for filename <- File.ls!(beam_dir) |> Enum.filter(&String.ends_with?(&1, beam_extension)) do 13 | binary = File.read!(Path.join(beam_dir, filename)) 14 | :code.load_binary(Path.rootname(filename) |> String.to_atom(), to_charlist(filename), binary) 15 | end 16 | 17 | # Load dependency tasks 18 | Mix.Task.load_tasks([beam_dir]) 19 | end 20 | 21 | # Run given task 22 | {task, args} = System.argv() |> Enum.at(-1) |> String.split() |> List.pop_at(0) 23 | Mix.Task.run(task, args) 24 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule LcovEx.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.3.4" 5 | 6 | def project do 7 | [ 8 | app: :lcov_ex, 9 | description: "Lcov test coverage file generator.", 10 | version: @version, 11 | elixir: "~> 1.9", 12 | start_permanent: Mix.env() == :prod, 13 | elixirc_paths: elixirc_paths(Mix.env()), 14 | deps: deps(), 15 | docs: docs(), 16 | package: package() 17 | ] 18 | end 19 | 20 | # Run "mix help compile.app" to learn about applications. 21 | def application do 22 | [ 23 | extra_applications: [:logger, :tools] 24 | ] 25 | end 26 | 27 | defp docs do 28 | [ 29 | source_ref: "v#{@version}", 30 | main: "readme", 31 | source_url: "https://github.com/dariodf/lcov_ex", 32 | extras: ["README.md"] 33 | ] 34 | end 35 | 36 | defp package do 37 | [ 38 | files: ~w(lib test mix.exs README.md LICENSE), 39 | maintainers: ["dariodf"], 40 | licenses: ["MIT"], 41 | links: %{"GitHub" => "https://github.com/dariodf/lcov_ex"} 42 | ] 43 | end 44 | 45 | defp elixirc_paths(:test), do: ["lib", "test/support"] 46 | defp elixirc_paths(_), do: ["lib"] 47 | 48 | defp deps do 49 | [ 50 | {:dialyxir, "~> 1.0", only: [:dev], runtime: false}, 51 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, 52 | {:mox, "~> 1.0", only: :test} 53 | ] 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "dialyxir": {:hex, :dialyxir, "1.0.0", "6a1fa629f7881a9f5aaf3a78f094b2a51a0357c843871b8bc98824e7342d00a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "aeb06588145fac14ca08d8061a142d52753dbc2cf7f0d00fc1013f53f8654654"}, 3 | "earmark_parser": {:hex, :earmark_parser, "1.4.25", "2024618731c55ebfcc5439d756852ec4e85978a39d0d58593763924d9a15916f", [:mix], [], "hexpm", "56749c5e1c59447f7b7a23ddb235e4b3defe276afc220a6227237f3efe83f51e"}, 4 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 5 | "ex_doc": {:hex, :ex_doc, "0.28.4", "001a0ea6beac2f810f1abc3dbf4b123e9593eaa5f00dd13ded024eae7c523298", [:mix], [{:earmark_parser, "~> 1.4.19", [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", "bf85d003dd34911d89c8ddb8bda1a958af3471a274a4c2150a9c01c78ac3f8ed"}, 6 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 7 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, 8 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 9 | "mox": {:hex, :mox, "1.0.2", "dc2057289ac478b35760ba74165b4b3f402f68803dd5aecd3bfd19c183815d64", [:mix], [], "hexpm", "f9864921b3aaf763c8741b5b8e6f908f44566f1e427b2630e89e9a73b981fef2"}, 10 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, 11 | } 12 | -------------------------------------------------------------------------------- /test/lcov_ex/formatter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LcovEx.FormatterTest do 2 | use ExUnit.Case 3 | 4 | describe "ExampleProject" do 5 | test "format_lcov" do 6 | assert LcovEx.Formatter.format_lcov( 7 | FakeModule, 8 | "path/to/file.ex", 9 | [{"foo/0", 1}, {"bar/2", 0}], 10 | 2, 11 | 1, 12 | [{3, 1}, {5, 0}], 13 | 2, 14 | 1 15 | ) 16 | |> IO.iodata_to_binary() == 17 | """ 18 | TN:Elixir.FakeModule 19 | SF:path/to/file.ex 20 | FNDA:1,foo/0 21 | FNDA:0,bar/2 22 | FNF:2 23 | FNH:1 24 | DA:3,1 25 | DA:5,0 26 | LF:2 27 | LH:1 28 | end_of_record 29 | """ 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/lcov_ex/stats_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LcovEx.StatsTest do 2 | use ExUnit.Case 3 | doctest LcovEx.Stats 4 | end 5 | -------------------------------------------------------------------------------- /test/lcov_ex_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LcovExTest do 2 | use ExUnit.Case 3 | alias LcovEx.Test.Support.MixFileHelper 4 | 5 | describe "ExampleProject" do 6 | setup do 7 | mix_path = "#{File.cwd!()}/example_project/mix.exs" |> String.replace("//", "/") 8 | MixFileHelper.backup(mix_path) 9 | config = [test_coverage: [tool: LcovEx, ignore_paths: ["deps/"]]] 10 | MixFileHelper.update_project_config(mix_path, config) 11 | 12 | on_exit(fn -> 13 | # Cleanup 14 | MixFileHelper.recover(mix_path) 15 | File.rm("example_project/cover/lcov.info") 16 | end) 17 | end 18 | 19 | test "run mix test --cover with LcovEx" do 20 | System.cmd("mix", ["test", "--cover"], cd: "example_project") 21 | 22 | assert File.read!("example_project/cover/lcov.info") == 23 | """ 24 | TN:Elixir.ExampleProject 25 | SF:lib/example_project.ex 26 | FNDA:1,covered/0 27 | FNDA:1,mocked/1 28 | FNDA:0,not_covered/0 29 | FNF:3 30 | FNH:2 31 | DA:5,1 32 | DA:9,1 33 | DA:13,0 34 | LF:3 35 | LH:2 36 | end_of_record 37 | TN:Elixir.ExampleProject.ExampleBehaviour 38 | SF:lib/example_project/example_behaviour.ex 39 | FNDA:1,call/1 40 | FNF:1 41 | FNH:1 42 | DA:6,1 43 | LF:1 44 | LH:1 45 | end_of_record 46 | TN:Elixir.ExampleProject.ExampleModule 47 | SF:lib/example_project/example_module.ex 48 | FNDA:1,cover/0 49 | FNDA:1,get_value/0 50 | FNF:2 51 | FNH:2 52 | DA:5,1 53 | DA:8,1 54 | LF:2 55 | LH:2 56 | end_of_record 57 | """ 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/support/mix_file_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule LcovEx.Test.Support.MixFileHelper do 2 | @type path :: binary() 3 | 4 | @doc """ 5 | Backup file in path by duplicating it into an `.old` file. 6 | """ 7 | @spec backup(path()) :: path() 8 | def backup(path) do 9 | path_old = "#{path}.old" 10 | File.cp!(path, path_old) 11 | path_old 12 | end 13 | 14 | @doc """ 15 | Recover file in path from preexisting `.old` file. 16 | """ 17 | @spec recover(path()) :: :ok 18 | def recover(path) do 19 | path_old = "#{path}.old" 20 | File.cp!(path_old, path) 21 | File.rm!(path_old) 22 | :ok 23 | end 24 | 25 | @doc """ 26 | Update mix project configurations. 27 | """ 28 | @spec update_project_config(path(), keyword()) :: :ok 29 | def update_project_config(mix_path, new_configs) do 30 | # Format mix.exs file 31 | System.cmd("mix", ["format", mix_path]) 32 | 33 | # Get file as AST representation 34 | {:defmodule, _, [_, [do: {_, _, ast_nodes}]]} = 35 | Code.string_to_quoted!(File.read!(mix_path), token_metadata: true) 36 | 37 | # Obtain the project AST node 38 | project_ast_node = 39 | Enum.find(ast_nodes, fn ast -> match?({:def, _, [{:project, _, _}, _]}, ast) end) 40 | 41 | # Get project config and file start and ending line numbers for replacement 42 | {_, token_metadata, [project_ast_tuple, [do: config]]} = project_ast_node 43 | project_start_line = token_metadata[:line] 44 | project_end_line = token_metadata[:end_of_expression][:line] 45 | 46 | # Update the configs 47 | # We try to maintain the key positions or append any new configs to the bottom 48 | new_config = 49 | Enum.reduce(new_configs, config, fn {key, value}, config -> 50 | case Keyword.pop(config, key) do 51 | {nil, list} -> list ++ [{key, value}] 52 | _ -> put_in(config[key], value) 53 | end 54 | end) 55 | 56 | new_project_ast_node = put_elem(project_ast_node, 2, [project_ast_tuple, [do: new_config]]) 57 | 58 | # Reconvert to string 59 | project_string_replacement = 60 | new_project_ast_node 61 | |> Macro.to_string() 62 | |> String.replace_prefix("def(project) do", "def project do") 63 | |> String.replace_suffix("end", "end\n") 64 | 65 | replace_range = project_start_line..project_end_line 66 | 67 | # Replace the project config into the file 68 | replace_range(mix_path, replace_range, project_string_replacement) 69 | 70 | # Format new mix.exs file 71 | System.cmd("mix", ["format", mix_path]) 72 | :ok 73 | end 74 | 75 | # 76 | # Private functions 77 | # 78 | 79 | defp replace_range(path, range, replacement) when is_binary(replacement) do 80 | path_tmp = "#{path}.tmp" 81 | File.cp!(path, path_tmp) 82 | 83 | try do 84 | File.rm!(path) 85 | new_file = File.open!(path, [:append]) 86 | 87 | File.open!(path_tmp, [:read], fn old_file -> 88 | copy_and_replace(old_file, new_file, range, replacement) 89 | end) 90 | catch 91 | _, _ -> 92 | File.cp!(path_tmp, path) 93 | after 94 | File.rm(path_tmp) 95 | end 96 | end 97 | 98 | defp copy_and_replace(old_file, new_file, range, string_replacement) do 99 | IO.read(old_file, :line) 100 | |> copy_and_replace(1, old_file, new_file, range, string_replacement) 101 | end 102 | 103 | defp copy_and_replace(:eof, _, _, _, _, _) do 104 | :ok 105 | end 106 | 107 | defp copy_and_replace(_, index, old_file, new_file, range_start.._ = range, string_replacement) 108 | when index == range_start do 109 | IO.write(new_file, string_replacement) 110 | 111 | IO.read(old_file, :line) 112 | |> copy_and_replace(index + 1, old_file, new_file, range, string_replacement) 113 | end 114 | 115 | defp copy_and_replace(line, index, old_file, new_file, range, string_replacement) do 116 | unless index in range, do: IO.write(new_file, line) 117 | 118 | IO.read(old_file, :line) 119 | |> copy_and_replace(index + 1, old_file, new_file, range, string_replacement) 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /test/tasks/lcov_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LcovEx.Tasks.LcovTest do 2 | use ExUnit.Case, async: true 3 | 4 | describe "ExampleProject" do 5 | setup do 6 | on_exit(fn -> 7 | # Cleanup 8 | File.rm("example_project/cover/lcov.info") 9 | end) 10 | end 11 | 12 | test "lcov task" do 13 | Mix.Project.in_project(:example_project, "example_project", fn _module -> 14 | assert Mix.Tasks.Lcov.run([]) 15 | end) 16 | 17 | assert File.read!("example_project/cover/lcov.info") == output() 18 | end 19 | 20 | test "mix lcov" do 21 | assert {output, 0} = System.cmd("mix", ["lcov"], cd: "example_project") 22 | 23 | assert output =~ "Generating lcov file..." 24 | assert output =~ "Coverage file created at cover/lcov.info" 25 | 26 | assert File.read!("example_project/cover/lcov.info") == output() 27 | end 28 | 29 | test "mix lcov --quiet" do 30 | assert {output, 0} = System.cmd("mix", ["lcov", "--quiet"], cd: "example_project") 31 | 32 | refute output =~ "Generating lcov file..." 33 | refute output =~ "Coverage file created at cover/lcov.info" 34 | 35 | assert File.read!("example_project/cover/lcov.info") == output() 36 | end 37 | 38 | test "mix lcov --output" do 39 | assert {output, 0} = 40 | System.cmd("mix", ["lcov", "--output", "coverage"], cd: "example_project") 41 | 42 | assert output =~ "Generating lcov file..." 43 | assert output =~ "Coverage file created at coverage/lcov.info" 44 | 45 | assert File.read!("example_project/coverage/lcov.info") == output() 46 | after 47 | File.rm_rf!("example_project/coverage") 48 | end 49 | 50 | test "mix lcov exits normally on failure" do 51 | assert {output, 0} = System.cmd("mix", ["lcov"], cd: "example_failing_project") 52 | 53 | assert output =~ "Generating lcov file..." 54 | assert output =~ "Coverage file created at cover/lcov.info" 55 | end 56 | 57 | test "mix lcov --exit returns a non-zero exit code on failure" do 58 | assert {output, 2} = System.cmd("mix", ["lcov", "--exit"], cd: "example_failing_project") 59 | 60 | assert output =~ "Generating lcov file..." 61 | assert output =~ "Coverage file created at cover/lcov.info" 62 | assert output =~ "2 tests, 2 failures" 63 | end 64 | 65 | test "mix lcov --fail-fast exits the run at the first failed test" do 66 | assert {output, 0} = System.cmd("mix", ["lcov", "--fail-fast"], cd: "example_failing_project") 67 | 68 | assert output =~ "--max-failures reached, aborting test suite" 69 | assert output =~ "1 test, 1 failure" 70 | end 71 | 72 | test "mix lcov --exit --fail-fast returns a non-zero code at the first failed test" do 73 | assert {output, 2} = System.cmd("mix", ["lcov", "--fail-fast", "--exit"], cd: "example_failing_project") 74 | 75 | assert output =~ "--max-failures reached, aborting test suite" 76 | assert output =~ "1 test, 1 failure" 77 | end 78 | end 79 | 80 | describe "ExampleUmbrellaProject" do 81 | setup do 82 | on_exit(fn -> 83 | # Cleanup 84 | File.rm("example_umbrella_project/cover/lcov.info") 85 | File.rm("example_umbrella_project/apps/example_project/cover/lcov.info") 86 | File.rm("example_umbrella_project/apps/example_project_2/cover/lcov.info") 87 | end) 88 | end 89 | 90 | test "lcov task" do 91 | Mix.Project.in_project(:example_umbrella_project, "example_umbrella_project", fn _module -> 92 | assert Mix.Tasks.Lcov.run([]) 93 | end) 94 | 95 | assert File.read!("example_umbrella_project/cover/lcov.info") == 96 | umbrella_output() <> umbrella_output_2() 97 | end 98 | 99 | test "mix lcov" do 100 | assert {output, 0} = System.cmd("mix", ["lcov"], cd: "example_umbrella_project") 101 | 102 | assert output =~ "Generating lcov file..." 103 | assert output =~ "Coverage file for umbrella created at cover/lcov.info" 104 | refute output =~ "apps/example_project/cover/lcov.info" 105 | refute output =~ "apps/example_project_2/cover/lcov.info" 106 | 107 | assert File.read!("example_umbrella_project/cover/lcov.info") == 108 | umbrella_output() <> umbrella_output_2() 109 | end 110 | 111 | test "mix lcov --keep" do 112 | assert {output, 0} = System.cmd("mix", ["lcov", "--keep"], cd: "example_umbrella_project") 113 | 114 | assert output =~ "Generating lcov file..." 115 | 116 | assert output =~ 117 | "Coverage file for example_project created at apps/example_project/cover/lcov.info" 118 | 119 | assert output =~ 120 | "Coverage file for example_project_2 created at apps/example_project_2/cover/lcov.info" 121 | 122 | assert output =~ "Coverage file for umbrella created at cover/lcov.info" 123 | 124 | assert File.read!("example_umbrella_project/cover/lcov.info") == 125 | umbrella_output() <> umbrella_output_2() 126 | end 127 | 128 | test "mix lcov on umbrella app without the dependency" do 129 | refute File.read!("example_umbrella_project/apps/example_project_2/mix.exs") =~ "lcov" 130 | 131 | assert {output, 0} = 132 | System.cmd("mix", ["lcov", "apps/example_project_2"], 133 | cd: "example_umbrella_project" 134 | ) 135 | 136 | assert output =~ "Generating lcov file..." 137 | refute output =~ "Coverage file created at cover/lcov.info" 138 | refute output =~ "Coverage file created at apps/example_project/cover/lcov.info" 139 | assert output =~ "Coverage file created at apps/example_project_2/cover/lcov.info" 140 | 141 | assert File.read!("example_umbrella_project/apps/example_project_2/cover/lcov.info") == 142 | umbrella_output_2() 143 | end 144 | end 145 | 146 | defp output do 147 | """ 148 | TN:Elixir.ExampleProject 149 | SF:lib/example_project.ex 150 | FNDA:1,covered/0 151 | FNDA:1,mocked/1 152 | FNDA:0,not_covered/0 153 | FNF:3 154 | FNH:2 155 | DA:5,1 156 | DA:9,1 157 | DA:13,0 158 | LF:3 159 | LH:2 160 | end_of_record 161 | TN:Elixir.ExampleProject.ExampleBehaviour 162 | SF:lib/example_project/example_behaviour.ex 163 | FNDA:1,call/1 164 | FNF:1 165 | FNH:1 166 | DA:6,1 167 | LF:1 168 | LH:1 169 | end_of_record 170 | TN:Elixir.ExampleProject.ExampleModule 171 | SF:lib/example_project/example_module.ex 172 | FNDA:1,cover/0 173 | FNDA:1,get_value/0 174 | FNF:2 175 | FNH:2 176 | DA:5,1 177 | DA:8,1 178 | LF:2 179 | LH:2 180 | end_of_record 181 | """ 182 | end 183 | 184 | defp umbrella_output do 185 | """ 186 | TN:Elixir.ExampleProject 187 | SF:apps/example_project/lib/example_project.ex 188 | FNDA:1,covered/0 189 | FNDA:0,not_covered/0 190 | FNF:2 191 | FNH:1 192 | DA:5,1 193 | DA:9,0 194 | LF:2 195 | LH:1 196 | end_of_record 197 | TN:Elixir.ExampleProject.ExampleModule 198 | SF:apps/example_project/lib/example_project/example_module.ex 199 | FNDA:1,cover/0 200 | FNDA:1,get_value/0 201 | FNF:2 202 | FNH:2 203 | DA:5,1 204 | DA:8,1 205 | LF:2 206 | LH:2 207 | end_of_record 208 | """ 209 | end 210 | 211 | defp umbrella_output_2 do 212 | """ 213 | TN:Elixir.ExampleProject2 214 | SF:apps/example_project_2/lib/example_project_2.ex 215 | FNDA:1,also_covered/0 216 | FNDA:1,covered/0 217 | FNF:2 218 | FNH:2 219 | DA:5,1 220 | DA:9,1 221 | LF:2 222 | LH:2 223 | end_of_record 224 | TN:Elixir.ExampleProject2.ExampleModule 225 | SF:apps/example_project_2/lib/example_project_2/example_module.ex 226 | FNDA:2,cover/0 227 | FNDA:2,get_value/0 228 | FNF:2 229 | FNH:2 230 | DA:5,2 231 | DA:8,2 232 | LF:2 233 | LH:2 234 | end_of_record 235 | """ 236 | end 237 | end 238 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------