├── .credo.exs ├── .dialyzer_ignore.exs ├── .formatter.exs ├── .github ├── actions │ └── elixir-setup │ │ └── action.yml ├── dependabot.yml └── workflows │ ├── elixir-build-and-test.yml │ └── elixir-quality-checks.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config └── runtime.exs ├── coveralls.json ├── lib ├── parameterized_test.ex └── parameterized_test │ ├── backtrace.ex │ ├── formatter.ex │ ├── parser.ex │ ├── sigil.ex │ ├── sigil_v114.exs │ └── sigil_v115.exs ├── mix.exs ├── mix.lock └── test ├── fixtures ├── empty.csv ├── empty.md ├── params.csv ├── params.md └── params.tsv ├── parameterized_test ├── backtrace_test.exs ├── formatter_test.exs ├── parser_test.exs ├── sigil_test.exs ├── sigil_v114.exs └── sigil_v115.exs ├── parameterized_test_test.exs ├── readme_test.exs └── test_helper.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any config using `mix credo -C `. If no config name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: [ 25 | "lib/", 26 | "src/", 27 | "test/", 28 | "web/", 29 | "apps/*/lib/", 30 | "apps/*/src/", 31 | "apps/*/test/", 32 | "apps/*/web/" 33 | ], 34 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 35 | }, 36 | # 37 | # Load and configure plugins here: 38 | # 39 | plugins: [], 40 | # 41 | # If you create your own checks, you must specify the source files for 42 | # them here, so they can be loaded by Credo before running the analysis. 43 | # 44 | requires: [], 45 | # 46 | # If you want to enforce a style guide and need a more traditional linting 47 | # experience, you can change `strict` to `true` below: 48 | # 49 | strict: false, 50 | # 51 | # To modify the timeout for parsing files, change this value: 52 | # 53 | parse_timeout: 5000, 54 | # 55 | # If you want to use uncolored output by default, you can change `color` 56 | # to `false` below: 57 | # 58 | color: true, 59 | # 60 | # You can customize the parameters of any check by adding a second element 61 | # to the tuple. 62 | # 63 | # To disable a check put `false` as second element: 64 | # 65 | # {Credo.Check.Design.DuplicatedCode, false} 66 | # 67 | checks: %{ 68 | enabled: [ 69 | # 70 | ## Consistency Checks 71 | # 72 | {Credo.Check.Consistency.ExceptionNames, []}, 73 | {Credo.Check.Consistency.LineEndings, []}, 74 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 75 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 76 | {Credo.Check.Consistency.SpaceInParentheses, []}, 77 | {Credo.Check.Consistency.TabsOrSpaces, []}, 78 | 79 | # 80 | ## Design Checks 81 | # 82 | # You can customize the priority of any check 83 | # Priority values are: `low, normal, high, higher` 84 | # 85 | {Credo.Check.Design.AliasUsage, [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, 86 | {Credo.Check.Design.DuplicatedCode, []}, 87 | # You can also customize the exit_status of each check. 88 | # If you don't want TODO comments to cause `mix credo` to fail, just 89 | # set this value to 0 (zero). 90 | # 91 | {Credo.Check.Design.TagTODO, false}, 92 | {Credo.Check.Design.TagFIXME, []}, 93 | 94 | # 95 | ## Readability Checks 96 | # 97 | {Credo.Check.Readability.AliasOrder, []}, 98 | {Credo.Check.Readability.FunctionNames, []}, 99 | {Credo.Check.Readability.ImplTrue, []}, 100 | {Credo.Check.Readability.LargeNumbers, []}, 101 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 102 | {Credo.Check.Readability.ModuleAttributeNames, []}, 103 | {Credo.Check.Readability.ModuleDoc, false}, 104 | {Credo.Check.Readability.ModuleNames, []}, 105 | {Credo.Check.Readability.ParenthesesInCondition, []}, 106 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 107 | {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, 108 | {Credo.Check.Readability.PredicateFunctionNames, []}, 109 | {Credo.Check.Readability.PreferImplicitTry, []}, 110 | {Credo.Check.Readability.RedundantBlankLines, []}, 111 | {Credo.Check.Readability.Semicolons, []}, 112 | {Credo.Check.Readability.SpaceAfterCommas, []}, 113 | {Credo.Check.Readability.Specs, [include_defp: false, files: %{excluded: "test/**/*"}]}, 114 | {Credo.Check.Readability.StringSigils, []}, 115 | {Credo.Check.Readability.TrailingBlankLine, []}, 116 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 117 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 118 | {Credo.Check.Readability.VariableNames, []}, 119 | {Credo.Check.Readability.WithSingleClause, []}, 120 | 121 | # 122 | ## Refactoring Opportunities 123 | # 124 | {Credo.Check.Refactor.Apply, []}, 125 | {Credo.Check.Refactor.CondStatements, []}, 126 | {Credo.Check.Refactor.CyclomaticComplexity, false}, 127 | {Credo.Check.Refactor.FilterFilter, []}, 128 | {Credo.Check.Refactor.FilterReject, []}, 129 | {Credo.Check.Refactor.FunctionArity, []}, 130 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 131 | {Credo.Check.Refactor.MatchInCondition, []}, 132 | {Credo.Check.Refactor.MatchInCondition, []}, 133 | {Credo.Check.Refactor.MapMap, []}, 134 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 135 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 136 | {Credo.Check.Refactor.NegatedIsNil, []}, 137 | {Credo.Check.Refactor.Nesting, [max_nesting: 4]}, 138 | {Credo.Check.Refactor.PassAsyncInTestCases, []}, 139 | {Credo.Check.Refactor.RejectFilter, []}, 140 | {Credo.Check.Refactor.UnlessWithElse, []}, 141 | {Credo.Check.Refactor.VariableRebinding, [files: %{excluded: "test/**/*"}]}, 142 | {Credo.Check.Refactor.WithClauses, []}, 143 | {Credo.Check.Refactor.RedundantWithClauseResult, []}, 144 | {Credo.Check.Refactor.RejectReject, []}, 145 | 146 | # 147 | ## Warnings 148 | # 149 | {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, 150 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 151 | {Credo.Check.Warning.Dbg, []}, 152 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 153 | {Credo.Check.Warning.IExPry, []}, 154 | {Credo.Check.Warning.IoInspect, []}, 155 | {Credo.Check.Warning.LeakyEnvironment, []}, 156 | {Credo.Check.Warning.MapGetUnsafePass, []}, 157 | {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []}, 158 | {Credo.Check.Warning.MixEnv, []}, 159 | {Credo.Check.Warning.OperationOnSameValues, []}, 160 | {Credo.Check.Warning.OperationWithConstantResult, []}, 161 | {Credo.Check.Warning.RaiseInsideRescue, []}, 162 | {Credo.Check.Warning.SpecWithStruct, []}, 163 | {Credo.Check.Warning.UnsafeExec, []}, 164 | {Credo.Check.Warning.UnsafeToAtom, []}, 165 | {Credo.Check.Warning.UnusedEnumOperation, []}, 166 | {Credo.Check.Warning.UnusedFileOperation, []}, 167 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 168 | {Credo.Check.Warning.UnusedListOperation, []}, 169 | {Credo.Check.Warning.UnusedPathOperation, []}, 170 | {Credo.Check.Warning.UnusedRegexOperation, []}, 171 | {Credo.Check.Warning.UnusedStringOperation, []}, 172 | {Credo.Check.Warning.UnusedTupleOperation, []}, 173 | {Credo.Check.Warning.WrongTestFileExtension, []} 174 | ], 175 | disabled: [ 176 | # 177 | # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) 178 | 179 | # 180 | # Controversial and experimental checks (opt-in, just move the check to `:enabled` 181 | # and be sure to use `mix credo --strict` to see low priority checks) 182 | # 183 | {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, 184 | {Credo.Check.Consistency.UnusedVariableNames, []}, 185 | {Credo.Check.Design.SkipTestWithoutComment, []}, 186 | {Credo.Check.Readability.AliasAs, []}, 187 | {Credo.Check.Readability.BlockPipe, []}, 188 | {Credo.Check.Readability.MultiAlias, []}, 189 | {Credo.Check.Readability.NestedFunctionCalls, []}, 190 | {Credo.Check.Readability.OneArityFunctionInPipe, []}, 191 | {Credo.Check.Readability.OnePipePerLine, []}, 192 | {Credo.Check.Readability.SeparateAliasRequire, []}, 193 | {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, 194 | {Credo.Check.Readability.SinglePipe, []}, 195 | {Credo.Check.Readability.StrictModuleLayout, []}, 196 | {Credo.Check.Readability.WithCustomTaggedTuple, []}, 197 | {Credo.Check.Refactor.ABCSize, []}, 198 | {Credo.Check.Refactor.AppendSingleItem, []}, 199 | {Credo.Check.Refactor.DoubleBooleanNegation, []}, 200 | {Credo.Check.Refactor.IoPuts, []}, 201 | {Credo.Check.Refactor.ModuleDependencies, []}, 202 | {Credo.Check.Refactor.PipeChainStart, []}, 203 | {Credo.Check.Warning.LazyLogging, []}, 204 | 205 | # 206 | # Custom checks can be created using `mix credo.gen.check`. 207 | # 208 | # Styler Rewrites 209 | # 210 | # The following rules are automatically rewritten by Styler and so disabled here to save time 211 | # Some of the rules have `priority: :high`, meaning Credo runs them unless we explicitly disable them 212 | # (removing them from this file wouldn't be enough, the `false` is required) 213 | # 214 | # Some rules have a comment before them explaining ways Styler deviates from the Credo rule. 215 | # 216 | # always expands `A.{B, C}` 217 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 218 | # including `case`, `fn` and `with` statements 219 | {Credo.Check.Consistency.ParameterPatternMatching, false}, 220 | {Credo.Check.Readability.AliasOrder, false}, 221 | {Credo.Check.Readability.BlockPipe, false}, 222 | # goes further than formatter - fixes bad underscores, eg: `100_00` -> `10_000` 223 | {Credo.Check.Readability.LargeNumbers, false}, 224 | # adds `@moduledoc false` 225 | {Credo.Check.Readability.ModuleDoc, false}, 226 | {Credo.Check.Readability.MultiAlias, false}, 227 | {Credo.Check.Readability.OneArityFunctionInPipe, false}, 228 | # removes parens 229 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, false}, 230 | {Credo.Check.Readability.PipeIntoAnonymousFunctions, false}, 231 | {Credo.Check.Readability.PreferImplicitTry, false}, 232 | {Credo.Check.Readability.SinglePipe, false}, 233 | # **potentially breaks compilation** - see **Troubleshooting** section below 234 | {Credo.Check.Readability.StrictModuleLayout, false}, 235 | {Credo.Check.Readability.UnnecessaryAliasExpansion, false}, 236 | {Credo.Check.Readability.WithSingleClause, false}, 237 | {Credo.Check.Refactor.CaseTrivialMatches, false}, 238 | {Credo.Check.Refactor.CondStatements, false}, 239 | # in pipes only 240 | {Credo.Check.Refactor.FilterCount, false}, 241 | # in pipes only 242 | {Credo.Check.Refactor.MapInto, false}, 243 | # in pipes only 244 | {Credo.Check.Refactor.MapJoin, false}, 245 | {Credo.Check.Refactor.NegatedConditionsInUnless, false}, 246 | {Credo.Check.Refactor.NegatedConditionsWithElse, false}, 247 | # allows ecto's `from 248 | {Credo.Check.Refactor.PipeChainStart, false}, 249 | {Credo.Check.Refactor.RedundantWithClauseResult, false}, 250 | {Credo.Check.Refactor.UnlessWithElse, false}, 251 | {Credo.Check.Refactor.WithClauses, false} 252 | ] 253 | } 254 | } 255 | ] 256 | } 257 | -------------------------------------------------------------------------------- /.dialyzer_ignore.exs: -------------------------------------------------------------------------------- 1 | # You can get the spec for these errors to ignore by running: 2 | # $ mix dialyzer --format short 3 | # ...and then taking that "short" output and either reproducing it whole here or by 4 | # pulling out the file and error atom. 5 | # 6 | # More info in the Dialyxir README: 7 | # https://github.com/jeremyjh/dialyxir#elixir-term-format 8 | [ 9 | {"deps/nimble_csv/lib/nimble_csv.ex", :unmatched_return} 10 | ] 11 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | plugins: [ 3 | Styler 4 | ], 5 | inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"] 6 | ] 7 | -------------------------------------------------------------------------------- /.github/actions/elixir-setup/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup Elixir Project 2 | description: Checks out the code, configures Elixir, fetches dependencies, and manages build caching. 3 | inputs: 4 | elixir-version: 5 | required: true 6 | type: string 7 | description: Elixir version to set up 8 | otp-version: 9 | required: true 10 | type: string 11 | description: OTP version to set up 12 | ################################################################# 13 | # Everything below this line is optional. 14 | # 15 | # It's designed to make compiling a reasonably standard Elixir 16 | # codebase "just work," though there may be speed gains to be had 17 | # by tweaking these flags. 18 | ################################################################# 19 | build-deps: 20 | required: false 21 | type: boolean 22 | default: true 23 | description: True if we should compile dependencies 24 | build-app: 25 | required: false 26 | type: boolean 27 | default: true 28 | description: True if we should compile the application itself 29 | build-flags: 30 | required: false 31 | type: string 32 | default: '--all-warnings' 33 | description: Flags to pass to mix compile 34 | install-rebar: 35 | required: false 36 | type: boolean 37 | default: true 38 | description: By default, we will install Rebar (mix local.rebar --force). 39 | install-hex: 40 | required: false 41 | type: boolean 42 | default: true 43 | description: By default, we will install Hex (mix local.hex --force). 44 | cache-key: 45 | required: false 46 | type: string 47 | default: 'v1' 48 | description: If you need to reset the cache for some reason, you can change this key. 49 | outputs: 50 | otp-version: 51 | description: "Exact OTP version selected by the BEAM setup step" 52 | value: ${{ steps.beam.outputs.otp-version }} 53 | elixir-version: 54 | description: "Exact Elixir version selected by the BEAM setup step" 55 | value: ${{ steps.beam.outputs.elixir-version }} 56 | runs: 57 | using: "composite" 58 | steps: 59 | - name: Setup elixir 60 | uses: erlef/setup-beam@v1 61 | id: beam 62 | with: 63 | elixir-version: ${{ inputs.elixir-version }} 64 | otp-version: ${{ inputs.otp-version }} 65 | 66 | - name: Get deps cache 67 | uses: actions/cache@v3 68 | with: 69 | path: deps/ 70 | key: deps-${{ inputs.cache-key }}-${{ runner.os }}-${{ hashFiles('**/mix.lock') }} 71 | restore-keys: | 72 | deps-${{ inputs.cache-key }}-${{ runner.os }}- 73 | 74 | - name: Get build cache 75 | uses: actions/cache@v3 76 | id: build-cache 77 | with: 78 | path: _build/${{env.MIX_ENV}}/ 79 | key: build-${{ inputs.cache-key }}-${{ runner.os }}-${{ inputs.otp-version }}-${{ inputs.elixir-version }}-${{ env.MIX_ENV }}-${{ hashFiles('**/mix.lock') }} 80 | restore-keys: | 81 | build-${{ inputs.cache-key }}-${{ runner.os }}-${{ inputs.otp-version }}-${{ inputs.elixir-version }}-${{ env.MIX_ENV }}- 82 | 83 | - name: Get Hex cache 84 | uses: actions/cache@v3 85 | id: hex-cache 86 | with: 87 | path: ~/.hex 88 | key: build-${{ runner.os }}-${{ inputs.otp-version }}-${{ inputs.elixir-version }}-${{ hashFiles('**/mix.lock') }} 89 | restore-keys: | 90 | build-${{ runner.os }}-${{ inputs.otp-version }}-${{ inputs.elixir-version }}- 91 | 92 | # In my experience, I have issues with incremental builds maybe 1 in 100 93 | # times that are fixed by doing a full recompile. 94 | # In order to not waste dev time on such trivial issues (while also reaping 95 | # the time savings of incremental builds for *most* day-to-day development), 96 | # I force a full recompile only on builds that we retry. 97 | - name: Clean to rule out incremental build as a source of flakiness 98 | if: github.run_attempt != '1' 99 | run: | 100 | mix deps.clean --all 101 | mix clean 102 | shell: sh 103 | 104 | - name: Install Rebar 105 | run: mix local.rebar --force 106 | shell: sh 107 | if: inputs.install-rebar == 'true' 108 | 109 | - name: Install Hex 110 | run: mix local.hex --force 111 | shell: sh 112 | if: inputs.install-hex == 'true' 113 | 114 | - name: Install Dependencies 115 | run: mix deps.get 116 | shell: sh 117 | 118 | # Normally we'd use `mix deps.compile` here, however that incurs a large 119 | # performance penalty when the dependencies are already fully compiled: 120 | # https://elixirforum.com/t/github-action-cache-elixir-always-recompiles-dependencies-elixir-1-13-3/45994/12 121 | # 122 | # Accoring to Jose Valim at the above link `mix loadpaths` will check and 123 | # compile missing dependencies 124 | - name: Compile Dependencies 125 | run: mix loadpaths 126 | shell: sh 127 | if: inputs.build-deps == 'true' 128 | 129 | - name: Compile Application 130 | run: mix compile ${{ inputs.build-flags }} 131 | shell: sh 132 | if: inputs.build-app == 'true' 133 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "12:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.github/workflows/elixir-build-and-test.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | jobs: 12 | build: 13 | name: Build and test 14 | runs-on: ubuntu-latest 15 | env: 16 | MIX_ENV: test 17 | strategy: 18 | matrix: 19 | elixir: ["1.14.4", "1.15.7", "1.16.0", "1.17.1"] 20 | otp: ["24.3.4", "25.3.2", "26.2.1"] 21 | exclude: 22 | # Elixir 1.17 doesn't support OTP 24 23 | - elixir: "1.17.1" 24 | otp: "24.3.4" 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v2 29 | 30 | - name: Setup Elixir Project 31 | uses: ./.github/actions/elixir-setup 32 | with: 33 | elixir-version: ${{ matrix.elixir }} 34 | otp-version: ${{ matrix.otp }} 35 | build-flags: --all-warnings --warnings-as-errors 36 | 37 | - name: Run Tests 38 | run: mix coveralls.json --warnings-as-errors --include feature --include integration 39 | if: always() 40 | 41 | # Optional, but Codecov has a bot that will comment on your PR with per-file 42 | # coverage deltas. 43 | - name: Upload to Codecov 44 | uses: codecov/codecov-action@v3 45 | with: 46 | token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos 47 | files: ./cover/excoveralls.json 48 | -------------------------------------------------------------------------------- /.github/workflows/elixir-quality-checks.yml: -------------------------------------------------------------------------------- 1 | name: Elixir Quality Checks 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | jobs: 12 | quality_checks: 13 | name: Formatting, Dialyzer, Credo, and Unused Deps 14 | runs-on: ubuntu-22.04 15 | env: 16 | MIX_ENV: dev 17 | elixir: "1.17.1" 18 | otp: "26.2.1" 19 | 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v2 23 | 24 | - name: Setup Elixir Project 25 | uses: ./.github/actions/elixir-setup 26 | with: 27 | elixir-version: ${{ env.elixir }} 28 | otp-version: ${{ env.otp }} 29 | build-app: false 30 | 31 | # Don't cache PLTs based on mix.lock hash, as Dialyzer can incrementally update even old ones 32 | # Cache key based on Elixir & Erlang version (also useful when running in matrix) 33 | - name: Restore PLT cache 34 | uses: actions/cache@v3 35 | id: plt_cache 36 | with: 37 | path: priv/plts 38 | key: plt-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('**/mix.lock') }}-${{ hashFiles('**/*.ex') }} 39 | restore-keys: | 40 | plt-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('**/mix.lock') }}-${{ hashFiles('**/*.ex') }} 41 | plt-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('**/mix.lock') }}- 42 | plt-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}- 43 | plt-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}- 44 | 45 | # Create PLTs if no cache was found. 46 | # Always rebuild PLT when a job is retried 47 | # (If they were cached at all, they'll be updated when we run mix dialyzer with no flags.) 48 | - name: Create PLTs 49 | if: steps.plt_cache.outputs.cache-hit != 'true' || github.run_attempt != '1' 50 | run: mix dialyzer --plt 51 | 52 | - name: Run Dialyzer 53 | run: mix dialyzer --format github 54 | 55 | - name: Check for unused deps 56 | run: mix deps.unlock --check-unused 57 | if: always() 58 | 59 | - name: Check code formatting 60 | run: mix format --check-formatted 61 | if: always() 62 | 63 | - name: Run Credo 64 | run: mix credo suggest --min-priority=normal 65 | if: always() 66 | 67 | - name: Check for compile-time dependencies 68 | run: mix xref graph --label compile-connected --fail-above 0 69 | if: always() 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | 3 | # The directory Mix will write compiled artifacts to. 4 | /_build/ 5 | 6 | # If you run "mix test --cover", coverage assets end up here. 7 | /cover/ 8 | 9 | # The directory Mix downloads your dependencies sources to. 10 | /deps/ 11 | 12 | # Where 3rd-party dependencies like ExDoc output generated docs. 13 | /doc/ 14 | 15 | # Ignore .fetch files in case you like to edit your project deps locally. 16 | /.fetch 17 | 18 | # If the VM crashes, it generates a dump, let's ignore it too. 19 | erl_crash.dump 20 | 21 | # Also ignore archive artifacts (built via "mix archive.build"). 22 | *.ez 23 | 24 | # Temporary files, for example, from tests. 25 | /tmp/ 26 | 27 | # Ignore assets that are produced by build tools. 28 | /priv/static/assets/ 29 | 30 | # Ignore digested assets cache. 31 | /priv/static/cache_manifest.json 32 | 33 | # In case you use Node.js/npm, you want to ignore these. 34 | npm-debug.log 35 | /assets/node_modules/ 36 | 37 | *.beam 38 | /config/*.secret.exs 39 | .elixir_ls/ 40 | priv/plts 41 | screenshots/ 42 | 43 | *.log 44 | *.crdownload 45 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.6.0 4 | 5 | ### New feature, and potentially breaking change: [Add the failing parameter line to the backtrace when a test fails](https://github.com/s3cur3/parameterized_test/pull/41) 6 | 7 | This change lets each `param_test`/`param_feature` carry forward the context of which row in your parameters table it's executing. Then, when the test fails, it will add that line of the file to the backtrace printed by ExUnit, along with the text of the row. 8 | 9 | For instance, consider this test that will fail 100% of the time: 10 | 11 | ```elixir 12 | param_test "gives the failing parameter row when a test fails", 13 | """ 14 | | should_fail? | description | 15 | | false | "Works" | 16 | | true | "Breaks" | 17 | """, 18 | %{should_fail?: should_fail?} do 19 | refute should_fail? 20 | end 21 | ``` 22 | 23 | When you run this, you'll get an ExUnit backtrace that looks like this: 24 | 25 | ``` 26 | 1) test gives the failing parameter row when a test fails - Breaks (ParameterizedTest.BacktraceTest) 27 | test/parameterized_test/backtrace_test.exs:1 28 | Expected truthy, got false 29 | code: assert not should_fail? 30 | stacktrace: 31 | test/parameterized_test/backtrace_test.exs:8: (test) 32 | test/parameterized_test/backtrace_test.exs:5: ParameterizedTest.BacktraceTest."| true | \"Breaks\" |"/0 33 | ``` 34 | 35 | This should make it easier to figure out which of your parameter rows caused the failing test. 36 | 37 | This is a breaking change if and only if you were using the included sigil (`~PARAMS` for Elixir v1.15+, or `~x` for Elixir v1.14). 38 | 39 | ### Important bug fix: [Support `@tag` module attributes applied to `param_test` or `param_feature` blocks](https://github.com/s3cur3/parameterized_test/pull/40) 40 | 41 | This bug has been there from the beginning, and was making it so that `@tag`s you thought were applying to all the parameterized tests in a block were in fact only applying to the first set of parameters. 42 | 43 | ## v0.5.0 44 | 45 | - New Mix formatter plugin for formatting the package's sigils, courtesy of @rschenk (🎉). To enable it, in your `formatter.exs`, add to following: 46 | ```elixir 47 | plugins: [ 48 | ParameterizedTest.Formatter, 49 | ], 50 | ``` 51 | You can see a brief video demo [on the PR](https://github.com/s3cur3/parameterized_test/pull/32). 52 | - Fixed an issue (#31) where `mix test --failed` could fail to run previously-failing tests because the way we were adding the parameters to the name (as a map) was not stable across runs. The consequence of this change is that the names of tests missing a description will change from listing parameters as maps to lists. 53 | - Example: suppose you previously have a `param_test` called `"checks equality"` with parameters `val_1: :a` and `val_b: :b`. It would previously have been given the full name `"checks equality (%{val_1: :a, val_2: :b})"` *or* `"checks equality (%{val_2: :b, val_1: :a})"`, and which you saw would change between test runs. In this release, it will consistently be given the name `"checks equality ([val_1: :a, val_2: :b])"`. 54 | 55 | ## v0.4.0 56 | 57 | - Adds a new `param_feature` macro, which wraps Wallaby's `feature` tests 58 | the same way `param_test` wraps ExUnit's `test`. 59 | 60 | (While you _can_ use the plain `param_test` macro in a test module that 61 | contains `use Wallaby.Feature`, doing so will break some Wallaby features 62 | including screenshot generation on failure.) 63 | - Moves the `parse_examples/2` function, an implementation detail for the 64 | `param_test` macro, into a new private module `ParameterizedTest.Parser`. 65 | 66 | ## v0.3.1 67 | 68 | Bug fix to accept more unquoted strings, including those that have Elixir delimiters in them like quotes, parentheses, etc. 69 | 70 | ## v0.3.0 71 | 72 | ### New features 73 | 74 | #### Support treating otherwise unparsable cells in your parameters table as strings 75 | 76 | This is a quality of life improvement for avoiding needing to add noise to string cells: 77 | 78 | ```elixir 79 | param_test "supports unquoted strings", 80 | """ 81 | | value | unquoted string | 82 | | 1, 2, 3 | The value is 1 | 83 | """, 84 | %{value: value, "unquoted string": unquoted} do 85 | assert value == "1, 2, 3" and unquoted == "The value is 1" 86 | end 87 | ``` 88 | 89 | #### Support a "description" column that will provide a custom name for each test. 90 | 91 | If you supply a column named `description`, `test_description`, or `test_desc`, we'll use that in the test name rather than simply dumping the values from the row in the test table. This lets you provide more human-friendly descriptions of why the test uses the values it does. 92 | 93 | A trivial example (which also takes advantage of the support for unquoted strings): 94 | 95 | ```elixir 96 | param_test "failing test", 97 | """ 98 | | value | description | 99 | | 1 | The value is 1 | 100 | """, 101 | %{value: value} do 102 | assert value == 2 103 | end 104 | ``` 105 | 106 | When you run this, the error will include the description ("The value is 1") in the test name: 107 | 108 | ``` 109 | 1) test failing test - The value is 1 (MyAppTest) 110 | test/my_app_test.exs:8 111 | Assertion with == failed 112 | code: assert value == 2 113 | left: 1 114 | right: 2 115 | stacktrace: 116 | test/my_app_test.exs:14: (test) 117 | ``` 118 | 119 | This is useful for communication with stakeholders, or for understanding what went wrong when a test fails. 120 | 121 | ## v0.2.0 122 | 123 | There are two new features in this release thanks to new contributor @axelson: 124 | 125 | * [Support longer test names](https://github.com/s3cur3/parameterized_test/pull/17) 126 | * [Support comments and Obsidian markdown table format](https://github.com/s3cur3/parameterized_test/pull/16) 127 | 128 | ## v0.1.0 129 | 130 | - Renamed to `ParameterizedTest`, with the accompanying macro `param_test`. 131 | (Why not `parameterized_test`? It's longer, harder to spell, and there are a lot of 132 | other accepted spellings, including "parameterised," "parametrized," and "parametrised.") 133 | - Added support for hand-rolled lists of parameters, like: 134 | 135 | ```elixir 136 | param_test "shipping policy matches the web site", 137 | [ 138 | # Items in the parameters list can be either maps... 139 | %{spending_by_category: %{pants: 29_99}, coupon: "FREE_SHIP"}, 140 | # ...or keyword lists 141 | [spending_by_category: %{shoes: 19_99, pants: 29_99}, coupon: nil] 142 | ], 143 | %{spending_by_category: spending_by_category, coupon: coupon} do 144 | ... 145 | end 146 | ``` 147 | - Added experimental support for populating test parameters from CSV and TSV files. 148 | Eventually I'd like to extend this to other sources like Notion documents. 149 | (Feedback welcome—just open an issue!) 150 | 151 | ## v0.0.1 152 | 153 | Initial release. 154 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Tyler A. Young 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 | # ParameterizedTest 2 | 3 | [![Hex.pm](https://img.shields.io/hexpm/v/parameterized_test)](https://hex.pm/packages/parameterized_test) [![Build and Test](https://github.com/s3cur3/parameterized_test/actions/workflows/elixir-build-and-test.yml/badge.svg)](https://github.com/s3cur3/parameterized_test/actions/workflows/elixir-build-and-test.yml) [![Elixir Quality Checks](https://github.com/s3cur3/parameterized_test/actions/workflows/elixir-quality-checks.yml/badge.svg)](https://github.com/s3cur3/parameterized_test/actions/workflows/elixir-quality-checks.yml) [![Code coverage](https://codecov.io/gh/s3cur3/parameterized_test/graph/badge.svg)](https://codecov.io/gh/s3cur3/parameterized_test) 4 | 5 | A utility for defining eminently readable parameterized (or example-based) tests in 6 | Elixir's ExUnit, inspired by [example tests in Cucumber](https://cucumber.io/docs/guides/10-minute-tutorial/?lang=java#using-variables-and-examples). 7 | 8 | ## What are parameterized tests? 9 | 10 | Parameterized tests let you define variables along a number of dimensions 11 | and re-run the same test body (including all `setup`) for each 12 | combination of variables. 13 | 14 | A simple example: 15 | 16 | ```elixir 17 | setup context do 18 | # context.permissions gets set by the param_test below 19 | permissions = Map.get(context, :permissions, nil) 20 | user = AccountsFixtures.user_fixture(permissions: permissions) 21 | %{user: user} 22 | end 23 | 24 | param_test "users with editor permissions or better can edit posts", 25 | """ 26 | | permissions | can_edit? | description | 27 | |-------------|-----------|---------------------------------| 28 | | :admin | true | Admins have max permissions | 29 | | :editor | true | Editors can edit (of course!) | 30 | | :viewer | false | Viewers are read-only | 31 | | nil | false | Anonymous viewers are read-only | 32 | """, 33 | %{user: user, permissions: permissions, can_edit?: can_edit?} do 34 | assert Posts.can_edit?(user) == can_edit?, "#{permissions} permissions should grant edit rights" 35 | end 36 | ``` 37 | 38 | That test will run 4 times, with the variables from from the table being applied 39 | to the test's context each time (and therefore being made 40 | available to the `setup` handler). These variables are: 41 | 42 | - `:permissions` 43 | - `:can_edit?` 44 | - the special `:description` variable (see 45 | [About test names, and improving debuggability](#about-test-names-and-improving-debuggability) 46 | for how this is used) 47 | 48 | Thus, under the hood this generates four unique tests, 49 | equivalent to doing something like this: 50 | 51 | ```elixir 52 | setup context do 53 | permissions = Map.get(context, :permissions, nil) 54 | user = AccountsFixtures.user_fixture{permissions: permissions} 55 | %{user: user} 56 | end 57 | 58 | for {permissions, can_edit?, description} <- [ 59 | {:admin, true, "Admins have max permissions"}, 60 | {:editor, true, "Editors can edit (of course!)"}, 61 | {:viewer, false, "Viewers are read-only"}, 62 | {nil, false, "Anonymous viewers are read-only"} 63 | ] do 64 | @permissions permissions 65 | @can_edit? can_edit? 66 | @description description 67 | 68 | @tag permissions: @permissions 69 | @tag can_edit?: @can_edit? 70 | @tag description: @description 71 | test "users with at least editor permissions can edit posts — #{@description}", %{user: user} do 72 | assert Posts.can_edit?(user) == @can_edit? 73 | end 74 | end 75 | end 76 | ``` 77 | 78 | As you can see, even with only 3 variables (just 2 that impact the test semantics!), 79 | the `for` comprehension comes with a lot of boilerplate. But the `param_test` 80 | macro supports an arbitrary number of variables, so you can describe complex 81 | business rules like "users get free shipping if they spend more than $100, 82 | or if they buy socks, or if they have the right coupon code": 83 | 84 | ```elixir 85 | param_test "grants free shipping based on the marketing site's stated policy", 86 | """ 87 | | spending_by_category | coupon | ships_free? | description | 88 | |-------------------------------|-------------|-------------|------------------| 89 | | %{shoes: 19_99, pants: 29_99} | | false | Spent too little | 90 | | %{shoes: 59_99, pants: 49_99} | | true | Spent over $100 | 91 | | %{socks: 10_99} | | true | Socks ship free | 92 | | %{pants: 1_99} | "FREE_SHIP" | true | Correct coupon | 93 | | %{pants: 1_99} | "FOO" | false | Incorrect coupon | 94 | """, 95 | %{ 96 | spending_by_category: spending_by_category, 97 | coupon: coupon, 98 | ships_free?: ships_free? 99 | } do 100 | shipping_cost = ShippingCalculator.calculate(spending_by_category, coupon) 101 | 102 | if ships_free? do 103 | assert shipping_cost == 0 104 | else 105 | assert shipping_cost > 0 106 | end 107 | end 108 | ``` 109 | 110 | The package also provides a second macro, `param_feature`, which wraps 111 | Wallaby's `feature` tests the same way `param_test` wraps ExUnit's `test`. 112 | (While you _can_ use the plain `param_test` macro in a test module that 113 | contains `use Wallaby.Feature`, doing so will break some Wallaby features 114 | including screenshot generation on failure.) 115 | 116 | ## Why parameterized testing? 117 | 118 | Parameterized testing reduces toil associated with writing tests that cover 119 | a wide variety of different example cases. It also localizes the test logic 120 | into a single place, so that at a glance you can see how a number of 121 | different factors affect the behavior of the system under test. 122 | 123 | As a bonus, a table of examples (with their expected results) often 124 | matches how the business communicates the requirements of a system, 125 | both internally and to customers—for instance, in a table describing 126 | shipping costs based on how much a customer spends, where they're 127 | located, whether they've bought a promotional product, etc. This means 128 | parameterized tests can often be initially created by pulling directly from 129 | a requirements document that your product folks provide, and the 130 | product folks can later read the tests (or at least the parameters table) 131 | if they want to verify the behavior of the system. 132 | 133 | ### Parameterized tests versus property tests 134 | 135 | Parameterized tests are superficially similar to property-based tests. 136 | Both allow you to write fewer tests while covering more of your system's 137 | behavior. This library is not a replacement for property tests, but 138 | rather complimentary to them. 139 | 140 | There are a few reasons you might choose to write a parameterized 141 | test rather than a property test: 142 | 143 | - **When describing policies, not invariants**: Much of a system's 144 | business logic comes down to arbitrary choices made by a product team. 145 | For instance, there's nothing in the abstract description of a shipping 146 | calculator that says buying socks or spending $100 total should grant 147 | you free shipping. Those aren't *principles* that every correctly 148 | implemented shipping system would implement. Instead, they're choices 149 | made by someone (maybe a product manager) which will in all likelihood 150 | be fiddled with over time. 151 | 152 | Contrast that with the classic use cases for property tests: every 153 | (correct) implementation of, say, `List.sort/1` will *always* have 154 | the dual properties of: 155 | 156 | 1. every element of the input being represented in the output, and 157 | 2. every element being "less than" the element after it. 158 | 159 | These sorting properties are *invariants* of the sorting function, 160 | and therefore are quite amenable to property testing. 161 | - **Ease of writing**: Property tests take a lot of practice to get 162 | good at writing. They're often quite time consuming to produce, and 163 | even when you think you've adequately described the parameters to 164 | the system. 165 | - **For communication with other stakeholders**: The table of examples 166 | in a parameterized test can be made readable by non-programmers (or 167 | non-Elixir programmers), so they can be a good way of showing others 168 | in your organization which behaviors of the system you've verified. 169 | Because they can compactly express a lot of test cases, they're 170 | much more suitable for this than saying "go read the title of 171 | every line in this file that starts with `test`." 172 | - **For verifying the exact scenarios described by other stakeholders**: 173 | Sometimes the edges of a particular behavior may be fuzzy—not just 174 | to you, but in the business domain as well. Hammering out hard-and-fast 175 | rules may not be necessary or worth it, so property tests that exercise 176 | the boundaries would be overkill. In contrast, when your product 177 | folks produce a document that describes the behavior of particular 178 | scenarios, you can encode that in a table and ensure that for the 179 | cases that are well-specified, the system behaves correctly. 180 | 181 | When would you write a property test instead of an example tests? 182 | 183 | - When you can specify true invariants about the desired behavior 184 | - When you want the absolute highest confidence in your code 185 | - When the correctness of a piece of code is important enough to merit 186 | a large time investment in getting the tests right 187 | - When the system's behavior at the edges is well specified 188 | 189 | And of course there's nothing wrong with using a mix of normal tests, 190 | parameterized tests, and property tests for a given piece of functionality. 191 | 192 | ## Installation and writing your first test 193 | 194 | 1. Add `parameterized_test` to your `mix.exs` dependencies: 195 | 196 | ```elixir 197 | def deps do 198 | [ 199 | {:parameterized_test, "~> 0.6", only: [:test]}, 200 | ] 201 | end 202 | ``` 203 | 2. Run `$ mix deps.get` to download the package 204 | 3. Write your first example test by adding `import ParameterizedTest` 205 | to the top of your test module, and using the `param_test` macro. 206 | 207 | You can optionally include a separator between the header and body 208 | of the table (like `|--------|-------|`), and a `description` column 209 | to improve the errors you get when your test fails (see 210 | [About test names, and improving debuggability](#about-test-names-and-improving-debuggability) 211 | for more on descriptions). 212 | 213 | The header of your table will be parsed as atoms to pass into your 214 | test context. The body cells of the table can be any valid Elixir 215 | expression, and empty cells will produce a `nil` value. 216 | 217 | A dummy example: 218 | 219 | ```elixir 220 | defmodule MyApp.MyModuleTest do 221 | use ExUnit.Case, async: true 222 | import ParameterizedTest 223 | 224 | param_test "behaves as expected", 225 | """ 226 | | variable_1 | variable_2 | etc | 227 | | ---------------- | -------------------- | ------- | 228 | | %{foo: :bar} | div(19, 3) | false | 229 | | "bip bop" | String.upcase("foo") | true | 230 | | ["whiz", "bang"] | :ok | | 231 | | | nil | "maybe" | 232 | """, 233 | %{ 234 | variable_1: variable_1, 235 | variable_2: variable_2, 236 | etc: etc 237 | } do 238 | assert MyModule.valid_combination?(variable_1, variable_2, etc) 239 | end 240 | end 241 | ``` 242 | 243 | 244 | ## Debugging failing tests 245 | 246 | As of v0.6.0, when you get a test failure, the backtrace that ExUnit prints will include 247 | the line in your file that provided the failing parameters. For instance, consider 248 | this test that will fail 100% of the time: 249 | 250 | ```elixir 251 | param_test "gives the failing parameter row when a test fails", 252 | """ 253 | | should_fail? | description | 254 | | false | "Works" | 255 | | true | "Breaks" | 256 | """, 257 | %{should_fail?: should_fail?} do 258 | refute should_fail? 259 | end 260 | ``` 261 | 262 | When you run it, you'll get an ExUnit backtrace that looks like this: 263 | 264 | ``` 265 | 1) test gives the failing parameter row when a test fails - Breaks (ParameterizedTest.BacktraceTest) 266 | test/parameterized_test/backtrace_test.exs:1 267 | Expected truthy, got false 268 | code: assert not should_fail? 269 | stacktrace: 270 | test/parameterized_test/backtrace_test.exs:8: (test) 271 | test/parameterized_test/backtrace_test.exs:5: ParameterizedTest.BacktraceTest."| true | \"Breaks\" |"/0 272 | ``` 273 | 274 | Use this line number to figure out which of your parameter rows caused the failing test. 275 | 276 | ### About test names, and improving debuggability 277 | 278 | ExUnit requires each test in a module to have a unique name. By default, 279 | without a `description` for the rows in your parameters table, 280 | `ParameterizedTest` appends a stringified version of the parameters 281 | passed to your test to the name you give the test. Consider this test: 282 | 283 | ```elixir 284 | param_test "checks equality", 285 | """ 286 | | val_1 | val_2 | 287 | | :a | :a | 288 | | :b | :c | 289 | """, 290 | %{val_1: val_1, val_2: val_2} do 291 | assert val_1 == val_2 292 | end 293 | ``` 294 | 295 | Under the hood, this produces two tests with the names: 296 | 297 | - `"checks equality ([val_1: :a, val_b: :a])"` 298 | - `"checks equality ([val_1: :b, val_b: :c])"` 299 | 300 | And if you ran this test, you'd get an error that looks like this: 301 | 302 | ``` 303 | 1) test checks equality ([val_1: :b, val_2: :c]) (MyModuleTest) 304 | test/my_module_test.exs:4 305 | Assertion with == failed 306 | code: assert val_1 == val_2 307 | left: :b 308 | right: :c 309 | stacktrace: 310 | test/my_module_test.exs:11: (test) 311 | ``` 312 | 313 | You can improve the names in the failure cases by providing a `description` 314 | column. When provided, that column will be used in the name. You may want 315 | to use this to explain *why* this combination of values should produce 316 | the expected outcome; for instance: 317 | 318 | ```elixir 319 | param_test "grants free shipping for spending $99 or more, or with coupon FREE_SHIP", 320 | """ 321 | | total_cents | coupon | free? | description | 322 | | ----------- | ----------- | ----- | --------------------------- | 323 | | 98_99 | | false | Spent too little | 324 | | 99_00 | | true | Min for free shipping | 325 | | 99_01 | | true | Spent more than the minimum | 326 | | 1_00 | "FREE_SHIP" | true | Had the right coupon | 327 | | 1_00 | "FOO" | false | Unrecognized coupon | 328 | """, %{total_cents: total_cents, coupon: coupon, free?: gets_free_shipping?} do 329 | shipping_cost = ShippingCalculator.calculate(total_cents, coupon) 330 | free_shipping? = shipping_cost == 0 331 | assert free_shipping? == gets_free_shipping? 332 | end 333 | ``` 334 | 335 | Suppose in your `ShippingCalculator` implementation, you mistakenly set 336 | the free shipping threshold to be _greater_ than $99.00, when your web site's 337 | state policy was $99 or more. You'd get an error when running this test that 338 | looks like this (note the first line ends with "Spent the min. for free shipping" from the `description` column): 339 | 340 | ``` 341 | 1) test grants free shipping for spending $99 or more, or with coupon FREE_SHIP - Spent the min. for free shipping (ShippingCalculatorTest) 342 | test/shipping/shipping_calculator_test.exs:34 343 | Assertion with == failed 344 | code: assert free_shipping? == gets_free_shipping? 345 | left: false 346 | right: true 347 | stacktrace: 348 | test/shipping/shipping_calculator_test.exs:47: (test) 349 | ``` 350 | 351 | 352 | ## Objections 353 | 354 | ### Why not just use the new `:parameterize` feature built into ExUnit in Elixir 1.18? 355 | 356 | Both this package and the 357 | [new, built-in parameterization](https://hexdocs.pm/ex_unit/main/ExUnit.Case.html#module-parameterized-tests) 358 | do similar things: they re-run the same test body with a different set of 359 | parameters. However, the built-in parameterization works by re-running 360 | your entire test module with each parameter set, so it's primarily aimed 361 | at cases where the tests should work the same regardless of the parameters. 362 | (The docs give the example of testing the `Registry` module and expecting 363 | it to behave the same regardless of how many partitions are used.) 364 | 365 | In contrast, the `param_test` macro is designed to use different parameters 366 | on a per-test basis, and it's expected that the parameters will cause different 367 | behavior between the test runs (and you'd generally expect to see one column 368 | that describes what the results should be). 369 | 370 | Finally, of course, there's the format of a `param_test`. The tabular, often 371 | quite human-friendly format encourages collaboration with less technical 372 | people on your team; your product manager may not be able to read a `for` 373 | comprehension, but if you link them to a Markdown table on GitHub that shows 374 | the test cases you've covered, they can probably make sense of them. 375 | 376 | ### "I hate the Markdown table syntax!" 377 | 378 | No sweat, you don't have to use it. You can instead pass a hand-rolled list of 379 | parameters to the `param_test` macro, like this: 380 | 381 | ```elixir 382 | param_test "shipping policy matches the web site", 383 | [ 384 | # Items in the parameters list can be either maps... 385 | %{spending_by_category: %{pants: 29_99}, coupon: "FREE_SHIP"}, 386 | # ...or keyword lists 387 | [spending_by_category: %{shoes: 19_99, pants: 29_99}, coupon: nil] 388 | ], 389 | %{spending_by_category: spending_by_category, coupon: coupon} do 390 | ... 391 | end 392 | ``` 393 | 394 | Just make sure that each item in the parameters list has the same keys. 395 | 396 | The final option is to pass a path to a *file* that contains your test parameters (we currently support `.md`/`.markdown`, `.csv`, and `.tsv` files), like this: 397 | 398 | ```elixir 399 | param_test "pull test parameters from a file", 400 | "test/fixtures/params.md", 401 | %{ 402 | spending_by_category: spending_by_category, 403 | coupon: coupon, 404 | gets_free_shipping?: gets_free_shipping? 405 | } do 406 | ... 407 | end 408 | ``` 409 | 410 | ## Advanced Configuration 411 | 412 | ### Use the `~PARAMS` sigil for automated Markdown table formatting 413 | 414 | If you would like some help from `mix format` to align that Markdown table syntax, 415 | you can use the optional [`~PARAMS`](https://hexdocs.pm/parameterized_test/ParameterizedTest.Sigil.html#sigil_PARAMS/2) sigil. 416 | 417 | First, you'll need to update the dependency configuration to include the `:dev` Mix 418 | environment. We want the library to be available when running `mix format`. 419 | 420 | ```diff 421 | - {:parameterized_test, "~> 0.6", only: [:test]}, 422 | + {:parameterized_test, "~> 0.6", only: [:dev, :test]}, 423 | ``` 424 | 425 | Next, update your project's `.formatter.exs` file and add 426 | `ParameterizedTest.Formatter` to the `plugins:` key. 427 | 428 | ```elixir 429 | plugins: [ 430 | ParameterizedTest.Formatter, 431 | ], 432 | ``` 433 | 434 | Finally, inside your test module, add `import ParameterizedTest.Sigil` to allow use 435 | of the `~PARAMS` sigil. 436 | 437 | With this sigil in use, any execution of `mix format` will realign your Markdown 438 | table syntax. Adding this can be particularly helpful if you collaborate 439 | with other developers. 440 | 441 | Sample usage of the sigil to ensure the formatter will automatically shrink or expand the columns in the future: 442 | ```elixir 443 | param_test "users with editor permissions or better can edit posts", 444 | ~PARAMS""" 445 | | permissions | can_edit? | description | 446 | |-------------|-----------|---------------------------------| 447 | | :admin | true | Admins have max permissions | 448 | | :editor | true | Editors can edit (of course!) | 449 | | :viewer | false | Viewers are read-only | 450 | | nil | false | Anonymous viewers are read-only | 451 | """, 452 | %{user: user, permissions: permissions, can_edit?: can_edit?} do 453 | assert Posts.can_edit?(user) == can_edit?, "#{permissions} permissions should grant edit rights" 454 | end 455 | ``` 456 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | if Mix.env() == :test do 4 | config :wallaby, 5 | driver: Wallaby.Chrome, 6 | screenshot_on_failure: true 7 | end 8 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "skip_files": [ 3 | "test/support" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /lib/parameterized_test.ex: -------------------------------------------------------------------------------- 1 | NimbleCSV.define(ParameterizedTest.TsvParser, separator: "\t", escape: "\"") 2 | NimbleCSV.define(ParameterizedTest.CsvParser, separator: ",", escape: "\"") 3 | 4 | defmodule ParameterizedTest do 5 | @moduledoc """ 6 | A utility for defining eminently readable parameterized (or example-based) tests. 7 | 8 | Parameterized tests look like this: 9 | 10 | param_test "grants free shipping based on the marketing site's stated policy", 11 | \"\"\" 12 | | spending_by_category | coupon | ships_free? | description | 13 | |-------------------------------|-------------|-------------|------------------| 14 | | %{shoes: 19_99, pants: 29_99} | | false | Spent too little | 15 | | %{shoes: 59_99, pants: 49_99} | | true | Spent $100+ | 16 | | %{socks: 10_99} | | true | Socks ship free | 17 | | %{pants: 1_99} | \"FREE_SHIP\" | true | Correct coupon | 18 | \"\"\", 19 | %{ 20 | spending_by_category: spending_by_category, 21 | coupon: coupon, 22 | ships_free?: ships_free? 23 | } do 24 | shipping_cost = ShippingCalculator.calculate_shipping(spending_by_category, coupon) 25 | free_shipping? = shipping_cost == 0 26 | assert free_shipping? == ships_free? 27 | end 28 | 29 | Alternatively, if you don't like the Markdown table format, you can supply a 30 | hand-rolled list of parameters to the `param_test` macro, like this: 31 | 32 | param_test "shipping policy matches the web site", 33 | [ 34 | # Items in the parameters list can be either maps... 35 | %{spending_by_category: %{pants: 29_99}, coupon: "FREE_SHIP"}, 36 | # ...or keyword lists 37 | [spending_by_category: %{shoes: 19_99, pants: 29_99}, coupon: nil] 38 | ], 39 | %{spending_by_category: spending_by_category, coupon: coupon} do 40 | ... 41 | end 42 | 43 | Just make sure that each item in the parameters list has the same keys. 44 | 45 | The final option is to pass a path to a *file* that contains your test parameters (we currently support `.md`/`.markdown`, `.csv`, and `.tsv` files), like this: 46 | 47 | ```elixir 48 | param_test "pull test parameters from a file", 49 | "test/fixtures/params.md", 50 | %{ 51 | spending_by_category: spending_by_category, 52 | coupon: coupon, 53 | gets_free_shipping?: gets_free_shipping? 54 | } do 55 | ... 56 | end 57 | ``` 58 | 59 | ## Why parameterized testing? 60 | 61 | Parameterized testing reduces toil associated with writing tests that cover 62 | a wide variety of different example cases. It also localizes the test logic 63 | into a single place, so that at a glance you can see how a number of 64 | different factors affect the behavior of the system under test. 65 | 66 | As a bonus, a table of examples (with their expected results) often 67 | matches how the business communicates the requirements of a system, 68 | both internally and to customers—for instance, in a table describing 69 | shipping costs based on how much a customer spends, where they're 70 | located, whether they've bought a promotional product, etc. This means 71 | parameterized tests can often be initially created by pulling directly from 72 | a requirements document that your product folks provide, and the 73 | product folks can later read the tests (or at least the parameters table) 74 | if they want to verify the behavior of the system. 75 | 76 | See the README for more information. 77 | """ 78 | alias ParameterizedTest.Parser 79 | 80 | require ParameterizedTest.Backtrace 81 | 82 | @doc """ 83 | Defines tests that use your parameters or example data. 84 | 85 | Use it like: 86 | 87 | param_test \"grants free shipping for spending $99+ or with coupon FREE_SHIP\", 88 | \"\"\" 89 | | total_cents | ships_free? | description | 90 | | ----------- | ----------- | --------------------------- | 91 | | 98_99 | false | Spent too little | 92 | | 99_00 | true | Min for free shipping | 93 | | 99_01 | true | Spent more than the minimum | 94 | \"\"\", 95 | %{total_cents: total_cents, ships_free?: ships_free?} do 96 | shipping_cost = ShippingCalculator.calculate(total_cents) 97 | 98 | if ships_free? do 99 | assert shipping_cost == 0 100 | else 101 | assert shipping_cost > 0 102 | end 103 | end 104 | 105 | """ 106 | defmacro param_test(test_name, examples, context_ast \\ quote(do: %{}), blocks) do 107 | quote location: :keep do 108 | context = 109 | __ENV__ 110 | |> Macro.Env.location() 111 | |> Keyword.put(:macro, :param_test) 112 | 113 | escaped_examples = Parser.escape_examples(unquote(examples), context) 114 | 115 | block_tags = Module.get_attribute(__MODULE__, :tag) 116 | 117 | for {{example, context}, index} <- Enum.with_index(escaped_examples) do 118 | for {key, val} <- example do 119 | @tag [{key, val}] 120 | end 121 | 122 | unquoted_test_name = unquote(test_name) 123 | full_test_name = Parser.full_test_name(unquoted_test_name, example, index, 212) 124 | 125 | # "Forward" tags defined on the param_test macro itself 126 | for [{key, val} | _] <- block_tags do 127 | @tag [{key, val}] 128 | end 129 | 130 | @param_test_context context 131 | 132 | @tag param_test: true 133 | test "#{full_test_name}", unquote(context_ast) do 134 | try do 135 | unquote(blocks) 136 | catch 137 | category, reason -> 138 | ParameterizedTest.Backtrace.add_test_context({category, reason}, __STACKTRACE__, @param_test_context) 139 | end 140 | end 141 | end 142 | end 143 | end 144 | 145 | if Code.ensure_loaded?(Wallaby) do 146 | @doc """ 147 | Defines Wallaby feature tests that use your parameters or example data. 148 | 149 | This is to the Wallaby `feature` macro as `param_test` is to `test`. 150 | 151 | Use it like this: 152 | 153 | param_feature \"supports Wallaby tests\", 154 | \"\"\" 155 | | text | url | 156 | |----------|----------------------| 157 | | \"GitHub\" | \"https://github.com\" | 158 | | \"Google\" | \"https://google.com\" | 159 | \"\"\", 160 | %{session: session, text: text, url: url} do 161 | session 162 | |> visit(url) 163 | |> assert_has(Wallaby.Query.text(text, minimum: 1)) 164 | end 165 | """ 166 | defmacro param_feature(test_name, examples, context_ast \\ quote(do: %{}), blocks) do 167 | quote location: :keep do 168 | use Wallaby.Feature 169 | 170 | context = 171 | __ENV__ 172 | |> Macro.Env.location() 173 | |> Keyword.put(:macro, :param_feature) 174 | 175 | escaped_examples = Parser.escape_examples(unquote(examples), context) 176 | 177 | block_tags = Module.get_attribute(__MODULE__, :tag) 178 | 179 | for {{example, context}, index} <- Enum.with_index(escaped_examples) do 180 | for {key, val} <- example do 181 | @tag [{key, val}] 182 | end 183 | 184 | unquoted_test_name = unquote(test_name) 185 | @full_test_name Parser.full_test_name(unquoted_test_name, example, index, 212) 186 | 187 | # "Forward" tags defined on the param_test macro itself 188 | for [{key, val} | _] <- block_tags do 189 | @tag [{key, val}] 190 | end 191 | 192 | @param_test_context context 193 | 194 | @tag param_test: true 195 | feature "#{@full_test_name}", unquote(context_ast) do 196 | try do 197 | unquote(blocks) 198 | catch 199 | category, reason -> 200 | ParameterizedTest.Backtrace.add_test_context({category, reason}, __STACKTRACE__, @param_test_context) 201 | end 202 | end 203 | end 204 | end 205 | end 206 | end 207 | end 208 | -------------------------------------------------------------------------------- /lib/parameterized_test/backtrace.ex: -------------------------------------------------------------------------------- 1 | defmodule ParameterizedTest.Backtrace do 2 | @moduledoc false 3 | alias ParameterizedTest.Parser 4 | 5 | @spec add_test_context({atom(), term()}, [tuple()], Parser.context()) :: no_return() 6 | def add_test_context({:error, %{__exception__: true} = exception}, bt, context) do 7 | reraise exception, augment_backtrace(bt, context) 8 | end 9 | 10 | def add_test_context({:error, payload}, bt, context) do 11 | reraise ErlangError.normalize(payload, bt), augment_backtrace(bt, context) 12 | end 13 | 14 | def add_test_context({kind, payload}, bt, context) do 15 | reraise RuntimeError.exception("#{kind}: #{inspect(payload)}"), augment_backtrace(bt, context) 16 | end 17 | 18 | defp augment_backtrace(bt, context) do 19 | test_idx = 20 | Enum.find_index(bt, fn {_m, f, _arity, _context} -> 21 | f 22 | |> to_string() 23 | |> String.starts_with?(["test ", "feature "]) 24 | end) 25 | 26 | {before_test, [test_line | after_test]} = Enum.split(bt, test_idx) 27 | 28 | {m, test_fun, _arity, _context} = test_line 29 | 30 | attributed_fun = function_to_attribute(test_fun, context) 31 | 32 | abs_path = context[:file] 33 | rel_path = Path.relative_to(abs_path, File.cwd!()) 34 | parameter_line = find_parameter_line(abs_path, context) 35 | parameter_stack_frame = {m, attributed_fun, 0, [file: rel_path, line: parameter_line]} 36 | 37 | before_test ++ [test_line, parameter_stack_frame | after_test] 38 | catch 39 | _, _ -> bt 40 | end 41 | 42 | defp function_to_attribute(test_fun, context) do 43 | case context[:raw] do 44 | table_data when is_binary(table_data) and table_data != "" -> 45 | truncated_name = 46 | if String.length(table_data) > 128 do 47 | String.slice(table_data, 0..128) <> "..." 48 | else 49 | table_data 50 | end 51 | 52 | # We're deliberately creating the atoms here. 53 | # There's no unbounded atom creation because presumably there are a limited 54 | # number of test failures you'll hit in one run of the test suite. 55 | # credo:disable-for-next-line Credo.Check.Warning.UnsafeToAtom 56 | String.to_atom(truncated_name) 57 | 58 | _ -> 59 | test_fun 60 | end 61 | end 62 | 63 | # Reads through the file contents, using the raw content of the line that produced 64 | # this parameterized test, to find the exact line that contains the parameters. 65 | # This is necessary because there may be whitespace added between the `param_test` line 66 | # and the actual parameters which is not visible based on the information we get from 67 | # the macro context. 68 | defp find_parameter_line(path, context) do 69 | default = context[:min_line] || context[:line] || 0 70 | 71 | case {context[:raw], context[:min_line]} do 72 | {raw, min_line} when is_binary(raw) and raw != "" and is_integer(min_line) -> 73 | offset_from_min = 74 | path 75 | |> File.read!() 76 | |> String.split(["\n", "\r\n"]) 77 | |> Enum.drop(min_line - 1) 78 | |> Enum.find_index(&String.contains?(&1, raw)) 79 | 80 | if is_nil(offset_from_min) do 81 | default 82 | else 83 | min_line + offset_from_min 84 | end 85 | 86 | _ -> 87 | default 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/parameterized_test/formatter.ex: -------------------------------------------------------------------------------- 1 | defmodule ParameterizedTest.Formatter do 2 | @moduledoc false 3 | @behaviour Mix.Tasks.Format 4 | 5 | alias Mix.Tasks.Format 6 | 7 | @impl Format 8 | def features(_opts) do 9 | sigil = if Version.match?(System.version(), "< 1.15.0"), do: :x, else: :PARAMS 10 | 11 | [sigils: [sigil], extensions: []] 12 | end 13 | 14 | @impl Format 15 | def format(contents, _opts) do 16 | contents 17 | |> ParameterizedTest.Parser.example_table_ast() 18 | |> format_table() 19 | end 20 | 21 | defp format_table(rows) do 22 | [header | _] = 23 | table_cells = 24 | rows 25 | |> Enum.filter(&match?({:cells, _}, &1)) 26 | |> Enum.map(&elem(&1, 1)) 27 | 28 | column_widths = 29 | Enum.reduce(table_cells, List.duplicate(0, length(header)), fn row_cells, acc -> 30 | row_cells 31 | |> Enum.map(&String.length/1) 32 | |> Enum.zip(acc) 33 | |> Enum.map(fn {a, b} -> max(a, b) end) 34 | end) 35 | 36 | Enum.map_join(rows, "\n", &format_row(&1, column_widths)) <> "\n" 37 | end 38 | 39 | defp format_row({:comment, c}, _widths), do: c 40 | 41 | defp format_row({:separator, pad_type}, widths) do 42 | padding = 43 | case pad_type do 44 | :padded -> " " 45 | :unpadded -> "-" 46 | end 47 | 48 | widths 49 | |> Enum.map_join("|", fn w -> 50 | [padding, String.duplicate("-", w), padding] 51 | end) 52 | |> borders() 53 | end 54 | 55 | defp format_row({:cells, cells}, widths) do 56 | cells 57 | |> Enum.zip(widths) 58 | |> Enum.map_join("|", fn {cell, w} -> " #{String.pad_trailing(cell, w)} " end) 59 | |> borders() 60 | end 61 | 62 | defp borders(str), do: "|#{str}|" 63 | end 64 | -------------------------------------------------------------------------------- /lib/parameterized_test/parser.ex: -------------------------------------------------------------------------------- 1 | defmodule ParameterizedTest.Parser do 2 | @moduledoc false 3 | require Logger 4 | 5 | @type context :: [{:line, integer} | {:file, String.t()}, ...] 6 | @type parsed_examples :: [{keyword(), context()}] 7 | 8 | defguardp is_valid_context(context) when is_list(context) and context != [] 9 | 10 | @spec escape_examples(String.t() | list, context()) :: parsed_examples() | keyword() 11 | def escape_examples(examples, context) do 12 | case examples do 13 | str when is_binary(str) -> 14 | file_extension = 15 | str 16 | |> Path.extname() 17 | |> String.downcase() 18 | 19 | case file_extension do 20 | ext when ext in [".md", ".markdown", ".csv", ".tsv"] -> 21 | parse_file_path_examples(str, context) 22 | 23 | _ -> 24 | str 25 | |> parse_examples(context) 26 | |> tap(fn 27 | [] -> 28 | file_path = Path.relative_to(context[:file] || "", File.cwd!()) 29 | Logger.warning("No parameters found in #{context[:macro] || "test"} at #{file_path}:#{context[:line]}") 30 | 31 | _ -> 32 | :ok 33 | end) 34 | end 35 | 36 | already_escaped when is_list(already_escaped) -> 37 | parse_examples(already_escaped, context) 38 | end 39 | end 40 | 41 | @spec parse_examples(String.t() | list, context()) :: parsed_examples() 42 | def parse_examples(table, context) 43 | 44 | def parse_examples(table, context) when is_binary(table) and is_valid_context(context) do 45 | table 46 | |> String.split("\n") 47 | |> Enum.map(&String.trim/1) 48 | |> parse_md_rows(context) 49 | end 50 | 51 | # This function head handles a list of already-parsed examples, like: 52 | # param_test "accepts a list of maps or keyword lists", 53 | # [ 54 | # [int_1: 99, int_2: 100], 55 | # %{int_1: 101, int_2: 102} 56 | # ], %{int_1: int_1, int_2: int_2} do 57 | def parse_examples(table, context) when is_list(table) and is_valid_context(context) do 58 | parse_hand_rolled_table(table, context) 59 | end 60 | 61 | @spec parse_file_path_examples(String.t(), context()) :: parsed_examples() 62 | def parse_file_path_examples(path, context) when is_valid_context(context) do 63 | file = File.read!(path) 64 | 65 | context = 66 | context 67 | |> Keyword.put(:file, path) 68 | |> Keyword.put(:line, 1) 69 | 70 | case path |> Path.extname() |> String.downcase() do 71 | md when md in [".md", ".markdown"] -> parse_examples(file, context) 72 | ".csv" -> parse_csv_file(file, context) 73 | ".tsv" -> parse_tsv_file(file, context) 74 | _ -> raise "Unsupported file extension for parameterized tests #{path} #{file_meta(context)}" 75 | end 76 | end 77 | 78 | @spec full_test_name(String.t(), map(), integer, integer) :: String.t() 79 | def full_test_name(original_test_name, example, row_index, max_chars) do 80 | custom_description = description(example) 81 | 82 | un_truncated_name = 83 | case custom_description do 84 | nil -> "#{original_test_name} (#{inspect(example)})" 85 | desc -> "#{original_test_name} - #{desc}" 86 | end 87 | 88 | cond do 89 | String.length(un_truncated_name) <= max_chars -> un_truncated_name 90 | is_nil(custom_description) -> "#{original_test_name} row #{row_index}" 91 | true -> String.slice(un_truncated_name, 0, max_chars) 92 | end 93 | end 94 | 95 | # Returns an AST of sorts (not an offical Elixir AST) representing an example 96 | # table created with heredocs or a sigil, with the intended consumer being 97 | # the sigil formatter 98 | @spec example_table_ast(String.t(), list()) :: 99 | [ 100 | {:cells, [String.t()]}, 101 | {:separator, :padded | :unpadded}, 102 | {:comment, String.t()} 103 | ] 104 | def example_table_ast(table, context \\ []) when is_binary(table) do 105 | table 106 | |> String.split("\n", trim: true) 107 | |> Enum.map(&String.trim/1) 108 | |> table_ast_rows(context) 109 | end 110 | 111 | defp description(kw_list) when is_list(kw_list) do 112 | kw_list 113 | |> Map.new() 114 | |> description() 115 | end 116 | 117 | defp description(%{test_description: desc}), do: desc 118 | defp description(%{test_desc: desc}), do: desc 119 | defp description(%{description: desc}), do: desc 120 | defp description(%{Description: desc}), do: desc 121 | defp description(_), do: nil 122 | 123 | @spec parse_hand_rolled_table(list(), context()) :: parsed_examples() 124 | defp parse_hand_rolled_table(evaled_table, context) when is_valid_context(context) do 125 | parsed_table = 126 | Enum.map(evaled_table, fn 127 | {values, context} when is_list(values) or is_map(values) -> {Keyword.new(values), context} 128 | other -> {Keyword.new(other), []} 129 | end) 130 | 131 | keys = 132 | parsed_table 133 | |> Enum.map(&elem(&1, 0)) 134 | |> MapSet.new(&Keyword.keys/1) 135 | 136 | if MapSet.size(keys) > 1 do 137 | raise """ 138 | The keys in each row must be the same across all rows in your example table. 139 | 140 | Found differing key sets#{file_meta(context)}: 141 | #{for key_set <- Enum.sort(keys), do: inspect(key_set)} 142 | """ 143 | end 144 | 145 | parsed_table 146 | end 147 | 148 | defp parse_csv_file(file, context) when is_valid_context(context) do 149 | file 150 | |> ParameterizedTest.CsvParser.parse_string(skip_headers: false) 151 | |> parse_csv_rows(context) 152 | end 153 | 154 | defp parse_tsv_file(file, context) when is_valid_context(context) do 155 | file 156 | |> ParameterizedTest.TsvParser.parse_string(skip_headers: false) 157 | |> parse_csv_rows(context) 158 | end 159 | 160 | @spec parse_md_rows([String.t()], context()) :: parsed_examples() 161 | defp parse_md_rows(rows, context) 162 | defp parse_md_rows([], _context), do: [] 163 | 164 | defp parse_md_rows([header | rows], context) when is_valid_context(context) do 165 | headers = 166 | header 167 | |> split_cells() 168 | |> Enum.map(&String.to_atom/1) 169 | 170 | rows 171 | # +1 to account for the header line. 172 | # Note that this may not be correct! In the ParameterizedTest.Backtrace module, 173 | # we'll use this as the place to start looking for the parameters line in the full 174 | # file contents (by reading the file and using the :raw context to find the text). 175 | |> Enum.with_index(context[:line] + 1) 176 | |> Enum.reject(fn {row, _index} -> separator_or_comment_row?(row) end) 177 | |> Enum.map(fn {row, index} -> 178 | context = 179 | context 180 | |> Keyword.put(:min_line, index) 181 | |> Keyword.put(:raw, row) 182 | 183 | cells = 184 | row 185 | |> split_cells() 186 | |> Enum.map(&eval_cell(&1, row, context)) 187 | 188 | check_cell_count!(cells, headers, row, context) 189 | 190 | {Enum.zip(headers, cells), context} 191 | end) 192 | |> Enum.reject(fn {row, _context} -> Enum.empty?(row) end) 193 | end 194 | 195 | defp parse_csv_rows(rows, context) 196 | defp parse_csv_rows([], _context), do: [] 197 | 198 | defp parse_csv_rows([header | rows], context) when is_valid_context(context) do 199 | headers = Enum.map(header, &String.to_atom/1) 200 | 201 | rows 202 | # Account for the header line 203 | |> Enum.with_index(context[:line] + 1) 204 | |> Enum.reject(fn {row, _index} -> separator_or_comment_row?(row) end) 205 | |> Enum.map(fn {row, index} -> 206 | context = Keyword.put(context, :line, index) 207 | cells = Enum.map(row, &eval_cell(&1, row, context)) 208 | 209 | check_cell_count!(cells, headers, row, context) 210 | 211 | {Enum.zip(headers, cells), context} 212 | end) 213 | |> Enum.reject(fn {row, _context} -> Enum.empty?(row) end) 214 | end 215 | 216 | defp eval_cell(cell, row, _context) do 217 | case Code.eval_string(cell, [], log: false) do 218 | {val, []} -> val 219 | _ -> raise "Failed to evaluate example cell `#{cell}` in row `#{row}`}" 220 | end 221 | rescue 222 | _e in [SyntaxError, CompileError, TokenMissingError] -> 223 | String.trim(cell) 224 | 225 | e -> 226 | reraise "Failed to evaluate example cell `#{cell}` in row `#{row}`. #{inspect(e)}", __STACKTRACE__ 227 | end 228 | 229 | defp check_cell_count!(cells, headers, row, context) do 230 | if length(cells) != length(headers) do 231 | raise """ 232 | The number of cells in each row must exactly match the 233 | number of headers on your example table. 234 | 235 | Problem row#{file_meta(context)}: 236 | #{row} 237 | 238 | Expected headers: 239 | #{inspect(headers)} 240 | """ 241 | end 242 | end 243 | 244 | defp table_ast_rows([header | _] = all_rows, context) do 245 | headers = split_cells(header) 246 | 247 | Enum.map(all_rows, fn row -> 248 | row 249 | |> classify_row() 250 | |> ast_parse_row() 251 | |> tap(fn 252 | {:cells, cells} -> check_cell_count!(cells, headers, row, context) 253 | _ -> nil 254 | end) 255 | end) 256 | end 257 | 258 | defp classify_row(row) do 259 | type = 260 | cond do 261 | separator_row?(row) -> :separator 262 | comment_row?(row) -> :comment 263 | true -> :cells 264 | end 265 | 266 | {type, row} 267 | end 268 | 269 | defp ast_parse_row({:cells, row}), do: {:cells, split_cells(row)} 270 | defp ast_parse_row({:comment, _} = row), do: row 271 | 272 | defp ast_parse_row({:separator, row}) do 273 | padding = if String.contains?(row, " "), do: :padded, else: :unpadded 274 | {:separator, padding} 275 | end 276 | 277 | defp split_cells(row) do 278 | row 279 | |> String.split("|", trim: true) 280 | |> Enum.map(&String.trim/1) 281 | end 282 | 283 | defp separator_or_comment_row?([]), do: true 284 | defp separator_or_comment_row?([cell]) when is_binary(cell), do: separator_or_comment_row?(cell) 285 | defp separator_or_comment_row?([_ | _]), do: false 286 | defp separator_or_comment_row?(""), do: true 287 | defp separator_or_comment_row?(row) when is_binary(row), do: separator_row?(row) or comment_row?(row) 288 | 289 | defp comment_row?(row), do: String.starts_with?(row, "#") 290 | 291 | # A regex to match rows consisting of pipes separated by hyphens, like |------|-----| 292 | @separator_regex ~r/^\|( ?-+ ?\|)+$/ 293 | 294 | defp separator_row?(row), do: Regex.match?(@separator_regex, row) 295 | 296 | defp file_meta(%{file: file, line: line}) when is_binary(file) and is_integer(line) do 297 | " (#{file}:#{line})" 298 | end 299 | 300 | defp file_meta(_), do: "" 301 | end 302 | -------------------------------------------------------------------------------- /lib/parameterized_test/sigil.ex: -------------------------------------------------------------------------------- 1 | if Version.match?(System.version(), "< 1.15.0") do 2 | Code.require_file("lib/parameterized_test/sigil_v114.exs") 3 | else 4 | Code.require_file("lib/parameterized_test/sigil_v115.exs") 5 | end 6 | 7 | # This module exists solely to force the sigil to be recompiled when 8 | # the included file changes. 9 | defmodule ParameterizedTest.SigilDependency do 10 | @moduledoc false 11 | if Version.match?(System.version(), "< 1.15.0") do 12 | @external_resource "lib/parameterized_test/sigil_v114.exs" 13 | else 14 | @external_resource "lib/parameterized_test/sigil_v115.exs" 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/parameterized_test/sigil_v114.exs: -------------------------------------------------------------------------------- 1 | defmodule ParameterizedTest.Sigil do 2 | @moduledoc """ 3 | Provides a sigil to wrap parsing of the example test tables. 4 | 5 | Note that on Elixir v1.15.0 and later, the sigil is named `~PARAMS`. 6 | Elixir v1.14 and earlier use `~x` since those versions don't support 7 | multi-character or uppercase sigils. 8 | 9 | The `param_test` macro automatically parses Markdown-style example tables 10 | into a list of maps for use in the test contexts, so this sigil is never 11 | *required* to be used. However, it may occasionally be useful to do things 12 | like declare a module attribute which is the pre-parsed example table, or to 13 | inspect how the table will be parsed. 14 | """ 15 | 16 | @doc ~S""" 17 | Provides a sigil for producing example data that you can use in tests. 18 | 19 | ### Examples 20 | 21 | You can have an arbitrary number of columns and rows. Headers are parsed 22 | as atoms, while the individual cells are parsed as Elixir values. 23 | 24 | iex> ~x\""" 25 | ...> | plan | user_permission | can_invite? | 26 | ...> | :free | :admin | true | 27 | ...> | :free | :editor | "maybe" | 28 | ...> | :free | :view_only | false | 29 | ...> | :standard | :admin | true | 30 | ...> | :standard | :editor | "tuesdays only" | 31 | ...> | :standard | :view_only | false | 32 | ...> \""" 33 | [ 34 | {%{plan: :free, user_permission: :admin, can_invite?: true}, _context}, 35 | {%{plan: :free, user_permission: :editor, can_invite?: "maybe"}, _context}, 36 | {%{plan: :free, user_permission: :view_only, can_invite?: false}, _context}, 37 | {%{plan: :standard, user_permission: :admin, can_invite?: true}, _context}, 38 | {%{plan: :standard, user_permission: :editor, can_invite?: "tuesdays only"}, _context}, 39 | {%{plan: :standard, user_permission: :view_only, can_invite?: false}, _context} 40 | ] 41 | 42 | You can optionally include separators between the headers and the data. 43 | 44 | iex> ~x\""" 45 | ...> | plan | user_permission | can_invite? | 46 | ...> |-----------|-----------------|-----------------| 47 | ...> | :free | :admin | true | 48 | ...> | :free | :editor | "maybe" | 49 | ...> \""" 50 | [ 51 | {%{plan: :free, user_permission: :admin, can_invite?: true}, _context}, 52 | {%{plan: :free, user_permission: :editor, can_invite?: "maybe"}, _context} 53 | ] 54 | """ 55 | # credo:disable-for-next-line Credo.Check.Readability.FunctionNames 56 | defmacro sigil_x(table, _opts \\ []) do 57 | quote do 58 | ParameterizedTest.Parser.parse_examples(unquote(table), file: __ENV__.file, line: __ENV__.line) 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/parameterized_test/sigil_v115.exs: -------------------------------------------------------------------------------- 1 | defmodule ParameterizedTest.Sigil do 2 | @moduledoc """ 3 | Provides a sigil to wrap parsing of the example test tables. 4 | 5 | The `param_test` macro automatically parses Markdown-style example tables 6 | into a list of maps for use in the test contexts, so this sigil is never 7 | *required* to be used. However, it may occasionally be useful to do things 8 | like declare a module attribute which is the pre-parsed example table, or to 9 | inspect how the table will be parsed. 10 | 11 | Note that on Elixir v1.14 and earlier, the sigil is `~x` since those versions 12 | don't support multi-character or uppercase sigils. 13 | """ 14 | 15 | @doc ~S""" 16 | Provides a sigil for producing example data that you can use in tests. 17 | 18 | ### Examples 19 | 20 | You can have an arbitrary number of columns and rows. Headers are parsed 21 | as atoms, while the individual cells are parsed as Elixir values. 22 | 23 | iex> ~PARAMS\""" 24 | ...> | plan | user_permission | can_invite? | 25 | ...> | :free | :admin | true | 26 | ...> | :free | :editor | "maybe" | 27 | ...> | :free | :view_only | false | 28 | ...> | :standard | :admin | true | 29 | ...> | :standard | :editor | "tuesdays only" | 30 | ...> | :standard | :view_only | false | 31 | ...> \""" 32 | [ 33 | {%{plan: :free, user_permission: :admin, can_invite?: true}, _context}, 34 | {%{plan: :free, user_permission: :editor, can_invite?: "maybe"}, _context}, 35 | {%{plan: :free, user_permission: :view_only, can_invite?: false}, _context}, 36 | {%{plan: :standard, user_permission: :admin, can_invite?: true}, _context}, 37 | {%{plan: :standard, user_permission: :editor, can_invite?: "tuesdays only"}, _context}, 38 | {%{plan: :standard, user_permission: :view_only, can_invite?: false}, _context} 39 | ] 40 | 41 | You can optionally include separators between the headers and the data. 42 | 43 | iex> ~PARAMS\""" 44 | ...> | plan | user_permission | can_invite? | 45 | ...> |-----------|-----------------|-----------------| 46 | ...> | :free | :admin | true | 47 | ...> | :free | :editor | "maybe" | 48 | ...> \""" 49 | [ 50 | {%{plan: :free, user_permission: :admin, can_invite?: true}, _context}, 51 | {%{plan: :free, user_permission: :editor, can_invite?: "maybe"}, _context} 52 | ] 53 | 54 | You can pass the output of `~PARAMS` directly to the `param_test` macro: 55 | 56 | param_test "distinguishes even and odd numbers", 57 | ~PARAMS\""" 58 | | even | odd | 59 | | 2 | 1 | 60 | | 4 | 3 | 61 | | 6 | 5 | 62 | \""", 63 | %{even: even, odd: odd} do 64 | assert rem(even, 2) == 0 65 | assert rem(odd, 2) == 1 66 | end 67 | """ 68 | # credo:disable-for-next-line Credo.Check.Readability.FunctionNames 69 | defmacro sigil_PARAMS(table, _opts \\ []) do 70 | quote do 71 | ParameterizedTest.Parser.parse_examples(unquote(table), file: __ENV__.file, line: __ENV__.line) 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ParameterizedTest.MixProject do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/s3cur3/parameterized_test" 5 | 6 | def project do 7 | [ 8 | app: :parameterized_test, 9 | version: "0.6.0", 10 | elixir: "~> 1.14", 11 | elixirc_paths: elixirc_paths(Mix.env()), 12 | start_permanent: Mix.env() == :prod, 13 | aliases: aliases(), 14 | deps: deps(), 15 | docs: docs(), 16 | description: "A utility for defining eminently readable parameterized (or example-based) tests in ExUnit", 17 | name: "ParameterizedTest", 18 | package: package(), 19 | test_coverage: [tool: ExCoveralls], 20 | preferred_cli_env: [ 21 | check: :test, 22 | "check.fast": :test, 23 | coveralls: :test, 24 | "coveralls.detail": :test, 25 | "coveralls.json": :test, 26 | "coveralls.html": :test, 27 | dialyzer: :dev, 28 | "test.all": :test 29 | ], 30 | dialyzer: [ 31 | ignore_warnings: ".dialyzer_ignore.exs", 32 | plt_add_apps: [:mix, :ex_unit], 33 | plt_file: {:no_warn, "priv/plts/dialyzer.plt"}, 34 | flags: [ 35 | :error_handling, 36 | :unknown, 37 | :unmatched_returns, 38 | :error_handling, 39 | :extra_return, 40 | :missing_return 41 | ], 42 | # Error out when an ignore rule is no longer useful so we can remove it 43 | list_unused_filters: true 44 | ] 45 | ] 46 | end 47 | 48 | def application do 49 | [] 50 | end 51 | 52 | defp docs do 53 | [ 54 | extras: ["CHANGELOG.md", "README.md"], 55 | main: "readme", 56 | source_url: @source_url, 57 | formatters: ["html"] 58 | ] 59 | end 60 | 61 | defp package do 62 | # These are the default files included in the package 63 | [ 64 | files: ["lib", "mix.exs", "README.md", "CHANGELOG.md"], 65 | maintainers: ["Tyler Young"], 66 | licenses: ["MIT"], 67 | links: %{"GitHub" => @source_url} 68 | ] 69 | end 70 | 71 | # Specifies which paths to compile per environment. 72 | defp elixirc_paths(:test), do: ["lib", "test/support"] 73 | defp elixirc_paths(_), do: ["lib"] 74 | 75 | # Specifies your project dependencies. 76 | # 77 | # Type `mix help deps` for examples and options. 78 | defp deps do 79 | List.flatten( 80 | [ 81 | # Optional: supports doing parameterization over Wallaby `feature` tests 82 | {:wallaby, ">= 0.0.0", optional: true}, 83 | {:nimble_csv, "~> 1.1"}, 84 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, 85 | 86 | # Code quality 87 | {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, 88 | {:dialyxir, "~> 1.2", only: [:dev, :test], runtime: false}, 89 | {:excoveralls, "~> 0.18.0", only: [:dev, :test], runtime: false} 90 | ] ++ styler_deps() 91 | ) 92 | end 93 | 94 | defp styler_deps do 95 | if Version.match?(System.version(), "< 1.15.0") do 96 | [] 97 | else 98 | [{:styler, "~> 1.3", only: [:dev, :test], runtime: false}] 99 | end 100 | end 101 | 102 | # Aliases are shortcuts or tasks specific to the current project. 103 | # For example, to install project dependencies and perform other setup tasks, run: 104 | # 105 | # $ mix setup 106 | # 107 | # See the documentation for `Mix` for more info on aliases. 108 | defp aliases do 109 | [ 110 | check: [ 111 | "clean", 112 | "check.fast", 113 | "test --only integration" 114 | ], 115 | "check.fast": [ 116 | "deps.unlock --check-unused", 117 | "compile --warnings-as-errors", 118 | "test", 119 | "check.quality" 120 | ], 121 | "check.quality": [ 122 | "format --check-formatted", 123 | "credo --strict", 124 | "check.circular", 125 | "check.dialyzer" 126 | ], 127 | "check.circular": "cmd MIX_ENV=dev mix xref graph --label compile-connected --fail-above 0", 128 | "check.dialyzer": "cmd MIX_ENV=dev mix dialyzer", 129 | setup: ["deps.get"], 130 | "test.all": ["test --include integration --include local_integration"] 131 | ] 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, 4 | "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, 5 | "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 7 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 8 | "ex_doc": {:hex, :ex_doc, "0.38.2", "504d25eef296b4dec3b8e33e810bc8b5344d565998cd83914ffe1b8503737c02", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "732f2d972e42c116a70802f9898c51b54916e542cc50968ac6980512ec90f42b"}, 9 | "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, 10 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 11 | "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, 12 | "httpoison": {:hex, :httpoison, "2.2.1", "87b7ed6d95db0389f7df02779644171d7319d319178f6680438167d7b69b1f3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "51364e6d2f429d80e14fe4b5f8e39719cacd03eb3f9a9286e61e216feac2d2df"}, 13 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 14 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 15 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 16 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 17 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 18 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 19 | "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, 20 | "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, 21 | "nimble_csv": {:hex, :nimble_csv, "1.2.0", "4e26385d260c61eba9d4412c71cea34421f296d5353f914afe3f2e71cce97722", [:mix], [], "hexpm", "d0628117fcc2148178b034044c55359b26966c6eaa8e2ce15777be3bbc91b12a"}, 22 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 23 | "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, 24 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 25 | "styler": {:hex, :styler, "1.4.2", "420da8a9d10324625b75690ca9f2468bc00ee6eb78dead827e562368f9feabbb", [:mix], [], "hexpm", "ca22538b203b2424eef99a227e081143b9a9a4b26da75f26d920537fcd778832"}, 26 | "tesla": {:hex, :tesla, "1.13.2", "85afa342eb2ac0fee830cf649dbd19179b6b359bec4710d02a3d5d587f016910", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "960609848f1ef654c3cdfad68453cd84a5febecb6ed9fed9416e36cd9cd724f9"}, 27 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 28 | "wallaby": {:hex, :wallaby, "0.30.10", "574afb8796521252daf49a4cd76a1c389d53cae5897f2d4b5f55dfae159c8e50", [:mix], [{:ecto_sql, ">= 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:httpoison, "~> 0.12 or ~> 1.0 or ~> 2.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_ecto, ">= 3.0.0", [hex: :phoenix_ecto, repo: "hexpm", optional: true]}, {:web_driver_client, "~> 0.2.0", [hex: :web_driver_client, repo: "hexpm", optional: false]}], "hexpm", "a8f89b92d8acce37a94b5dfae6075c2ef00cb3689d6333f5f36c04b381c077b2"}, 29 | "web_driver_client": {:hex, :web_driver_client, "0.2.0", "63b76cd9eb3b0716ec5467a0f8bead73d3d9612e63f7560d21357f03ad86e31a", [:mix], [{:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:tesla, "~> 1.3", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "83cc6092bc3e74926d1c8455f0ce927d5d1d36707b74d9a65e38c084aab0350f"}, 30 | } 31 | -------------------------------------------------------------------------------- /test/fixtures/empty.csv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/s3cur3/parameterized_test/eec7b4a1500539000ca833bd6535ae85d4542b10/test/fixtures/empty.csv -------------------------------------------------------------------------------- /test/fixtures/empty.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/s3cur3/parameterized_test/eec7b4a1500539000ca833bd6535ae85d4542b10/test/fixtures/empty.md -------------------------------------------------------------------------------- /test/fixtures/params.csv: -------------------------------------------------------------------------------- 1 | coupon,gets_free_shipping? 2 | ,false 3 | """FREE_SHIP""",true 4 | -------------------------------------------------------------------------------- /test/fixtures/params.md: -------------------------------------------------------------------------------- 1 | | spending_by_category | coupon | gets_free_shipping? | 2 | |-------------------------------|-------------|---------------------| 3 | | %{shoes: 19_99, pants: 29_99} | | false | 4 | | %{shoes: 59_99, pants: 49_99} | | true | 5 | | %{socks: 10_99} | | true | 6 | | %{shoes: 19_99} | "FREE_SHIP" | true | 7 | -------------------------------------------------------------------------------- /test/fixtures/params.tsv: -------------------------------------------------------------------------------- 1 | coupon gets_free_shipping? 2 | false 3 | """FREE_SHIP""" true 4 | -------------------------------------------------------------------------------- /test/parameterized_test/backtrace_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ParameterizedTest.BacktraceTest do 2 | # Tyler notes: This module is *extremely* brittle, because the line numbers are hardcoded. 3 | # Pretty much any change to existing code will break a number of tests. 4 | # This is to be expected, and is kind of the price to pay for getting really 5 | # obviously correct behavior here. 6 | use ExUnit.Case, async: true 7 | 8 | import ParameterizedTest 9 | 10 | param_test "gives the failing parameter row when a test fails", 11 | """ 12 | | should_fail? | 13 | | false | 14 | | true | 15 | """, 16 | %{should_fail?: should_fail?} do 17 | first_test_line = __ENV__.line 18 | 19 | if should_fail? do 20 | context = [file: __ENV__.file, min_line: first_test_line - 7, raw: "| true |"] 21 | 22 | try do 23 | assert not should_fail? 24 | catch 25 | category, reason -> 26 | try do 27 | ParameterizedTest.Backtrace.add_test_context({category, reason}, __STACKTRACE__, context) 28 | rescue 29 | ExUnit.AssertionError -> 30 | assert [failing_line, parameter_line | _] = __STACKTRACE__ 31 | 32 | assert {__MODULE__, f1, 1, context1} = failing_line 33 | assert f1 == :"test gives the failing parameter row when a test fails ([should_fail?: true])" 34 | assert context1[:line] == first_test_line + 6 35 | 36 | assert {__MODULE__, f2, 0, context2} = parameter_line 37 | # credo:disable-for-next-line Credo.Check.Warning.UnsafeToAtom 38 | assert f2 == String.to_atom(context[:raw]) 39 | assert context2[:line] == first_test_line - 3 40 | end 41 | end 42 | else 43 | assert not should_fail? 44 | end 45 | end 46 | 47 | test "translates Erlang errors" do 48 | assert_raise ArithmeticError, fn -> 49 | context = [file: __ENV__.file, min_line: __ENV__.line, raw: "| true |"] 50 | ParameterizedTest.Backtrace.add_test_context({:error, :badarith}, [], context) 51 | end 52 | end 53 | 54 | test "turns other errors into RuntimeErrors" do 55 | assert_raise RuntimeError, fn -> 56 | context = [file: __ENV__.file, min_line: __ENV__.line, raw: "| true |"] 57 | ParameterizedTest.Backtrace.add_test_context({:timeout, {GenServer, :call, [self(), :slow_call, 0]}}, [], context) 58 | end 59 | end 60 | 61 | @tag failure_with_backtrace: true 62 | param_test "points to line #{__ENV__.line + 4} when a test fails", 63 | """ 64 | | should_fail? | description | 65 | | false | "Works" | 66 | | true | "Breaks" | 67 | """, 68 | %{should_fail?: should_fail?} do 69 | assert not should_fail? 70 | end 71 | 72 | @tag failure_with_backtrace: true 73 | param_test "with added comments, should point to line #{__ENV__.line + 7}", 74 | # This is a comment to break the normal (inferrable) line number 75 | # Another line to screw it up! 76 | """ 77 | | variable_1 | variable_2 | 78 | # Comment that should be skipped in the line count 79 | # Comment that should be skipped in the line count 80 | | "foo" | "012345678911234567892123456789312345678941234567895123456789612345678971234567898123456789912345678901234567891123456789212345678931234567894123456789512345678961234567897123456789812345678991234567890123456789112345678921234567893123456789412345678951234567896123456789712345678981234567899123456789" | 81 | """, 82 | %{variable_1: variable_1, variable_2: variable_2} do 83 | assert variable_1 == "foo" 84 | assert variable_2 == "012345678911234567892123456" 85 | end 86 | 87 | @tag failure_with_backtrace: true 88 | param_test "with a description, should point to line #{__ENV__.line + 4}", 89 | # This is a comment to break the normal (inferrable) line number 90 | """ 91 | | variable_1 | variable_2 | description | 92 | | "foo" | "bar" | "should fail" | 93 | """, 94 | %{variable_1: variable_1, variable_2: variable_2} do 95 | assert variable_1 == "foo" 96 | assert variable_2 != "bar" 97 | end 98 | 99 | @tag failure_with_backtrace: true 100 | param_test( 101 | "with parens, should point to line #{__ENV__.line + 3}", 102 | """ 103 | | variable_1 | variable_2 | 104 | | "foo" | "bar" | 105 | """, 106 | %{variable_1: variable_1, variable_2: variable_2} 107 | ) do 108 | assert variable_1 == "foo" 109 | assert variable_2 != "bar" 110 | end 111 | 112 | @tag skip: true 113 | @tag failure_with_backtrace: true 114 | param_feature( 115 | "feature with parens should point to line #{__ENV__.line + 4}", 116 | """ 117 | | variable_1 | variable_2 | 118 | 119 | | "foo" | "bar" | 120 | """, 121 | %{variable_1: variable_1, variable_2: variable_2} 122 | ) do 123 | assert variable_1 == "foo" 124 | assert variable_2 != "bar" 125 | end 126 | 127 | @tag failure_with_backtrace: true 128 | param_test "hand-rolled params shouldn't give attribution", [[variable_1: "foo", variable_2: "bar"]], %{ 129 | variable_1: variable_1, 130 | variable_2: variable_2 131 | } do 132 | assert variable_1 == "foo" 133 | assert variable_2 != "bar" 134 | end 135 | 136 | @tag skip: true 137 | @tag failure_with_backtrace: true 138 | param_feature "hand-rolled params shouldn't give attribution", [[variable_1: "foo", variable_2: "bar"]], %{ 139 | variable_1: variable_1, 140 | variable_2: variable_2 141 | } do 142 | assert variable_1 == "foo" 143 | assert variable_2 != "bar" 144 | end 145 | 146 | @tag failure_with_backtrace: true 147 | param_test "attributes Markdown error to test/fixtures/params.md line 6", 148 | "test/fixtures/params.md", 149 | %{coupon: coupon} do 150 | assert is_nil(coupon) 151 | end 152 | 153 | @tag failure_with_backtrace: true 154 | param_test "attributes CSV error to test/fixtures/params.csv line 2", "test/fixtures/params.csv", %{ 155 | gets_free_shipping?: gets_free_shipping? 156 | } do 157 | assert gets_free_shipping? 158 | end 159 | 160 | @tag failure_with_backtrace: true 161 | param_test "attributes TSV error to test/fixtures/params.tsv line 3", "test/fixtures/params.tsv", %{ 162 | gets_free_shipping?: gets_free_shipping? 163 | } do 164 | assert not gets_free_shipping? 165 | end 166 | 167 | @tag failure_with_backtrace: true 168 | param_test "handles other exceptions, attribute to line #{__ENV__.line + 4}", 169 | """ 170 | | should_fail? | 171 | | false | 172 | | true | 173 | """, 174 | %{should_fail?: should_fail?} do 175 | if should_fail? do 176 | raise "test failed" 177 | else 178 | assert 1 == 1 179 | end 180 | end 181 | 182 | @tag failure_with_backtrace: true 183 | param_test "handles code errors, attribute to line #{__ENV__.line + 4}", 184 | """ 185 | | should_fail? | 186 | | false | 187 | | true | 188 | """, 189 | %{should_fail?: should_fail?} do 190 | if should_fail? do 191 | assert Code.eval_string("nil + 1") == 2 192 | else 193 | assert 1 == 1 194 | end 195 | end 196 | 197 | defmodule SlowGenServer do 198 | @moduledoc false 199 | @behaviour GenServer 200 | 201 | def start_link(opts \\ []), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__) 202 | 203 | @impl GenServer 204 | def init(_), do: {:ok, []} 205 | 206 | def slow_call(pid) do 207 | GenServer.call(pid, :slow_call, 0) 208 | end 209 | 210 | @impl GenServer 211 | def handle_call(:slow_call, _from, state) do 212 | :timer.sleep(1000) 213 | {:reply, :ok, state} 214 | end 215 | end 216 | 217 | @tag failure_with_backtrace: true 218 | param_test "handles non-assertion errors too, attribute to line #{__ENV__.line + 4}", 219 | """ 220 | | should_fail? | 221 | | false | 222 | | true | 223 | """, 224 | %{should_fail?: should_fail?} do 225 | if should_fail? do 226 | {:ok, pid} = SlowGenServer.start_link() 227 | SlowGenServer.slow_call(pid) 228 | end 229 | end 230 | 231 | @tag skip: true 232 | @tag failure_with_backtrace: true 233 | param_feature "handles non-assertion errors in features, attribute to line #{__ENV__.line + 4}", 234 | """ 235 | | should_fail? | 236 | | false | 237 | | true | 238 | """, 239 | %{should_fail?: should_fail?} do 240 | if should_fail? do 241 | {:ok, pid} = SlowGenServer.start_link() 242 | SlowGenServer.slow_call(pid) 243 | end 244 | end 245 | end 246 | -------------------------------------------------------------------------------- /test/parameterized_test/formatter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ParameterizedTest.FormatterTest do 2 | use ExUnit.Case, async: true 3 | 4 | describe "format/2" do 5 | test "formats a table" do 6 | input = 7 | """ 8 | |a|b| 9 | | - | - | 10 | |"string"|:atom| 11 | |123|%{d: [1.0]}| 12 | # comment 13 | | | "" | 14 | """ 15 | 16 | expected_output = 17 | """ 18 | | a | b | 19 | | -------- | ----------- | 20 | | "string" | :atom | 21 | | 123 | %{d: [1.0]} | 22 | # comment 23 | | | "" | 24 | """ 25 | 26 | assert ParameterizedTest.Formatter.format(input, []) == expected_output 27 | end 28 | 29 | test "respects unpadded separator rows" do 30 | input = 31 | """ 32 | |a|b| 33 | |-|-| 34 | |"string"|:atom| 35 | |123|%{d: [1.0]}| 36 | # comment 37 | | | "" | 38 | """ 39 | 40 | expected_output = 41 | """ 42 | | a | b | 43 | |----------|-------------| 44 | | "string" | :atom | 45 | | 123 | %{d: [1.0]} | 46 | # comment 47 | | | "" | 48 | """ 49 | 50 | assert ParameterizedTest.Formatter.format(input, []) == expected_output 51 | end 52 | 53 | test "works when the table is too wide also" do 54 | input = 55 | """ 56 | | a | b | 57 | |---------------|-------------| 58 | | "string" | :atom | 59 | | 123 | %{d: [1.0]} | 60 | # comment 61 | | | "" | 62 | """ 63 | 64 | expected_output = 65 | """ 66 | | a | b | 67 | |----------|-------------| 68 | | "string" | :atom | 69 | | 123 | %{d: [1.0]} | 70 | # comment 71 | | | "" | 72 | """ 73 | 74 | assert ParameterizedTest.Formatter.format(input, []) == expected_output 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /test/parameterized_test/parser_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ParameterizedTest.ParserTest do 2 | use ExUnit.Case, async: true 3 | 4 | describe "parse_examples/1" do 5 | test "accepts strings that parse as empty" do 6 | for empty <- ["", " ", "\n", "\t", "\r\n", "\n\n \r\n \t \r"] do 7 | assert ParameterizedTest.Parser.parse_examples(empty, file: __ENV__.file, line: __ENV__.line) == [] 8 | end 9 | end 10 | end 11 | 12 | describe "example_table_ast/1" do 13 | test "returns a representation of the raw values in an example table" do 14 | assert ParameterizedTest.Parser.example_table_ast(""" 15 | | a | b | 16 | |----------|-------------| 17 | | "string" | :atom | 18 | # comment 19 | | 123 | %{d: [1.0]} | 20 | | | "" | 21 | """) == [ 22 | {:cells, ["a", "b"]}, 23 | {:separator, :unpadded}, 24 | {:cells, ["\"string\"", ":atom"]}, 25 | {:comment, "# comment"}, 26 | {:cells, ["123", "%{d: [1.0]}"]}, 27 | {:cells, ["", "\"\""]} 28 | ] 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/parameterized_test/sigil_test.exs: -------------------------------------------------------------------------------- 1 | if Version.match?(System.version(), "< 1.15.0") do 2 | Code.require_file("test/parameterized_test/sigil_v114.exs") 3 | else 4 | Code.require_file("test/parameterized_test/sigil_v115.exs") 5 | end 6 | -------------------------------------------------------------------------------- /test/parameterized_test/sigil_v114.exs: -------------------------------------------------------------------------------- 1 | defmodule ParameterizedTest.SigilTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ParameterizedTest 5 | import ParameterizedTest.Sigil 6 | 7 | test "basic examples" do 8 | params_header_line = __ENV__.line + 3 9 | 10 | parsed = ~x""" 11 | | plan | user_permission | can_invite? | 12 | | :free | :admin | true | 13 | | :free | :editor | "maybe" | 14 | | :free | :view_only | false | 15 | | :standard | :admin | true | 16 | | :standard | :editor | "tuesdays only" | 17 | | :standard | :view_only | false | 18 | """ 19 | 20 | assert Enum.map(parsed, &elem(&1, 0)) == [ 21 | [plan: :free, user_permission: :admin, can_invite?: true], 22 | [plan: :free, user_permission: :editor, can_invite?: "maybe"], 23 | [plan: :free, user_permission: :view_only, can_invite?: false], 24 | [plan: :standard, user_permission: :admin, can_invite?: true], 25 | [plan: :standard, user_permission: :editor, can_invite?: "tuesdays only"], 26 | [plan: :standard, user_permission: :view_only, can_invite?: false] 27 | ] 28 | 29 | contexts = Enum.map(parsed, &elem(&1, 1)) 30 | [first_context | _] = contexts 31 | assert first_context[:min_line] == params_header_line 32 | 33 | assert Enum.map(contexts, & &1[:min_line]) == 34 | Enum.to_list(first_context[:min_line]..(first_context[:min_line] + length(parsed) - 1)) 35 | 36 | assert Enum.all?(contexts, &(&1[:file] == __ENV__.file)) 37 | end 38 | 39 | test "discards headers" do 40 | params_header_line = __ENV__.line + 3 41 | 42 | parsed = ~x""" 43 | | plan | user_permission | can_invite? | 44 | |-----------|-----------------|-----------------| 45 | | :free | :admin | true | 46 | | :free | :editor | "maybe" | 47 | """ 48 | 49 | assert Enum.map(parsed, &elem(&1, 0)) == [ 50 | [plan: :free, user_permission: :admin, can_invite?: true], 51 | [plan: :free, user_permission: :editor, can_invite?: "maybe"] 52 | ] 53 | 54 | contexts = Enum.map(parsed, &elem(&1, 1)) 55 | [first_context | _] = contexts 56 | assert first_context[:min_line] == params_header_line + 1 57 | 58 | assert Enum.map(contexts, & &1[:min_line]) == 59 | Enum.to_list(first_context[:min_line]..(first_context[:min_line] + length(parsed) - 1)) 60 | 61 | assert Enum.all?(contexts, &(&1[:file] == __ENV__.file)) 62 | end 63 | 64 | test "allows any expression" do 65 | assert [ 66 | {[string: "FOO", integer: 4, keyword_list: [foo: :bar, baz: :bang]], _context} 67 | ] = ~x""" 68 | | string | integer | keyword_list | 69 | | String.upcase("foo") | div(17, 4) | [foo: :bar, baz: :bang] | 70 | """ 71 | end 72 | 73 | param_test "param_test accepts pre-parsed values from ~x sigil", 74 | ~x""" 75 | | int_1 | int_2 | 76 | | 2 | 4 | 77 | """, 78 | %{int_1: int_1, int_2: int_2} do 79 | assert int_1 == 2 80 | assert int_2 == 4 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /test/parameterized_test/sigil_v115.exs: -------------------------------------------------------------------------------- 1 | defmodule ParameterizedTest.SigilTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ParameterizedTest 5 | import ParameterizedTest.Sigil, only: :sigils 6 | 7 | test "basic examples" do 8 | params_header_line = __ENV__.line + 3 9 | 10 | parsed = ~PARAMS""" 11 | | plan | user_permission | can_invite? | 12 | | :free | :admin | true | 13 | | :free | :editor | "maybe" | 14 | | :free | :view_only | false | 15 | | :standard | :admin | true | 16 | | :standard | :editor | "tuesdays only" | 17 | | :standard | :view_only | false | 18 | """ 19 | 20 | assert Enum.map(parsed, &elem(&1, 0)) == [ 21 | [plan: :free, user_permission: :admin, can_invite?: true], 22 | [plan: :free, user_permission: :editor, can_invite?: "maybe"], 23 | [plan: :free, user_permission: :view_only, can_invite?: false], 24 | [plan: :standard, user_permission: :admin, can_invite?: true], 25 | [plan: :standard, user_permission: :editor, can_invite?: "tuesdays only"], 26 | [plan: :standard, user_permission: :view_only, can_invite?: false] 27 | ] 28 | 29 | contexts = Enum.map(parsed, &elem(&1, 1)) 30 | [first_context | _] = contexts 31 | assert first_context[:min_line] == params_header_line 32 | 33 | assert Enum.map(contexts, & &1[:min_line]) == 34 | Enum.to_list(first_context[:min_line]..(first_context[:min_line] + length(parsed) - 1)) 35 | 36 | assert Enum.all?(contexts, &(&1[:file] == __ENV__.file)) 37 | end 38 | 39 | test "discards headers" do 40 | parsed = ~PARAMS""" 41 | | plan | user_permission | can_invite? | 42 | |-----------|-----------------|-----------------| 43 | | :free | :admin | true | 44 | | :free | :editor | "maybe" | 45 | """ 46 | 47 | assert [ 48 | {[plan: :free, user_permission: :admin, can_invite?: true], _}, 49 | {[plan: :free, user_permission: :editor, can_invite?: "maybe"], _} 50 | ] = parsed 51 | end 52 | 53 | test "allows any expression" do 54 | assert [{[string: "FOO", integer: 4, keyword_list: [foo: :bar, baz: :bang]], context}] = ~PARAMS""" 55 | | string | integer | keyword_list | 56 | | String.upcase("foo") | div(17, 4) | [foo: :bar, baz: :bang] | 57 | """ 58 | 59 | macro_line = __ENV__.line - 5 60 | 61 | assert Enum.sort(context) == 62 | Enum.sort( 63 | file: __ENV__.file, 64 | line: macro_line, 65 | min_line: macro_line + 1, 66 | raw: "| String.upcase(\"foo\") | div(17, 4) | [foo: :bar, baz: :bang] |" 67 | ) 68 | end 69 | 70 | param_test "param_test accepts pre-parsed values from ~x sigil", 71 | ~PARAMS""" 72 | | int_1 | int_2 | 73 | | 2 | 4 | 74 | """, 75 | %{int_1: int_1, int_2: int_2} do 76 | assert int_1 == 2 77 | assert int_2 == 4 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /test/parameterized_test_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ParameterizedTestTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ParameterizedTest 5 | 6 | doctest ParameterizedTest, import: true 7 | 8 | defmodule ExampleShippingCalculator do 9 | @moduledoc false 10 | def calculate_shipping(spending_by_category, coupon) do 11 | bought_socks? = Map.get(spending_by_category, :socks, 0) > 0 12 | 13 | total_spent = 14 | spending_by_category 15 | |> Map.values() 16 | |> Enum.sum() 17 | 18 | if bought_socks? or total_spent > 10_000 or coupon == "FREE_SHIP" do 19 | 0 20 | else 21 | 5_00 22 | end 23 | end 24 | end 25 | 26 | describe "shipping policy" do 27 | param_test "matches stated policy on the marketing site", 28 | """ 29 | | spending_by_category | coupon | gets_free_shipping? | 30 | |-------------------------------|-------------|---------------------| 31 | | %{shoes: 19_99, pants: 29_99} | | false | 32 | | %{shoes: 59_99, pants: 49_99} | | true | 33 | | %{socks: 10_99} | | true | 34 | | %{shoes: 19_99} | "FREE_SHIP" | true | 35 | """, 36 | %{ 37 | spending_by_category: spending_by_category, 38 | coupon: coupon, 39 | gets_free_shipping?: gets_free_shipping? 40 | } do 41 | shipping_cost = ExampleShippingCalculator.calculate_shipping(spending_by_category, coupon) 42 | free_shipping? = shipping_cost == 0 43 | assert free_shipping? == gets_free_shipping? 44 | end 45 | 46 | param_test "ignores commented lines", 47 | """ 48 | | spending_by_category | coupon | gets_free_shipping? | 49 | |-------------------------------|-------------|---------------------| 50 | # Temporarily disable 51 | #| %{shoes: 19_99, pants: 29_99} | | false | 52 | | %{shoes: 59_99, pants: 49_99} | | true | 53 | """, 54 | %{ 55 | spending_by_category: spending_by_category, 56 | coupon: coupon, 57 | gets_free_shipping?: gets_free_shipping? 58 | } do 59 | shipping_cost = ExampleShippingCalculator.calculate_shipping(spending_by_category, coupon) 60 | free_shipping? = shipping_cost == 0 61 | assert free_shipping? == gets_free_shipping? 62 | end 63 | 64 | param_test "supports obsidian-flavored markdown table separator rows", 65 | """ 66 | | spending_by_category | coupon | gets_free_shipping? | 67 | | ----------------------------- | ----------- | ------------------- | 68 | | %{shoes: 59_99, pants: 49_99} | | true | 69 | """, 70 | %{ 71 | spending_by_category: spending_by_category, 72 | coupon: coupon, 73 | gets_free_shipping?: gets_free_shipping? 74 | } do 75 | shipping_cost = ExampleShippingCalculator.calculate_shipping(spending_by_category, coupon) 76 | free_shipping? = shipping_cost == 0 77 | assert free_shipping? == gets_free_shipping? 78 | end 79 | 80 | param_test "supports Markdown files as input", 81 | "test/fixtures/params.md", 82 | %{ 83 | spending_by_category: spending_by_category, 84 | coupon: coupon, 85 | gets_free_shipping?: gets_free_shipping? 86 | } do 87 | shipping_cost = ExampleShippingCalculator.calculate_shipping(spending_by_category, coupon) 88 | free_shipping? = shipping_cost == 0 89 | assert free_shipping? == gets_free_shipping? 90 | end 91 | 92 | param_test "does not run empty markdown files", "test/fixtures/empty.md", %{} do 93 | flunk("should not run") 94 | end 95 | 96 | param_test "supports CSV files as input", 97 | "test/fixtures/params.csv", 98 | %{ 99 | coupon: coupon, 100 | gets_free_shipping?: gets_free_shipping? 101 | } do 102 | assert (coupon == "FREE_SHIP" and gets_free_shipping?) or (is_nil(coupon) and not gets_free_shipping?) 103 | end 104 | 105 | param_test "does not run empty CSV files", "test/fixtures/empty.csv", %{} do 106 | flunk("should not run") 107 | end 108 | 109 | param_test "supports TSV files as input", 110 | "test/fixtures/params.tsv", 111 | %{ 112 | coupon: coupon, 113 | gets_free_shipping?: gets_free_shipping? 114 | } do 115 | assert (coupon == "FREE_SHIP" and gets_free_shipping?) or (is_nil(coupon) and not gets_free_shipping?) 116 | end 117 | end 118 | 119 | defmodule ExampleAccounts do 120 | @moduledoc false 121 | def create_user(%{permissions: permissions}) do 122 | %{permissions: permissions} 123 | end 124 | end 125 | 126 | describe "makes values available to `setup`" do 127 | setup %{int_1: int_1, int_2: int_2} do 128 | %{int_1: int_1 * 2, int_2: int_2 * 2} 129 | end 130 | 131 | param_test "and allows them to be modified", 132 | """ 133 | | int_1 | int_2 | 134 | | 2 | 4 | 135 | """, 136 | %{int_1: int_1, int_2: int_2} do 137 | assert int_1 == 4 138 | assert int_2 == 8 139 | end 140 | end 141 | 142 | describe "with a very, very, very, very, very, very, very, very, very long `describe` title" do 143 | param_test "truncates extremely long contexts to avoid overflowing the atom length limit", 144 | """ 145 | | variable_1 | variable_2 | 146 | | "foo" | "012345678911234567892123456789312345678941234567895123456789612345678971234567898123456789912345678901234567891123456789212345678931234567894123456789512345678961234567897123456789812345678991234567890123456789112345678921234567893123456789412345678951234567896123456789712345678981234567899123456789" | 147 | """, 148 | %{variable_1: variable_1, variable_2: variable_2, test: test} do 149 | assert variable_1 == "foo" 150 | 151 | assert variable_2 == 152 | "012345678911234567892123456789312345678941234567895123456789612345678971234567898123456789912345678901234567891123456789212345678931234567894123456789512345678961234567897123456789812345678991234567890123456789112345678921234567893123456789412345678951234567896123456789712345678981234567899123456789" 153 | 154 | assert String.length(variable_2) == 300 155 | 156 | test_name = Atom.to_string(test) 157 | assert String.length(test_name) <= 255 158 | 159 | assert test_name == 160 | "test with a very, very, very, very, very, very, very, very, very long `describe` title truncates extremely long contexts to avoid overflowing the atom length limit row 0" 161 | end 162 | end 163 | 164 | param_test "interprets otherwise unparseable values as strings", 165 | """ 166 | | value | unquoted string key | 167 | |---------|------------------------------| 168 | | 1 | foo, bar & baz | 169 | | 2 3 4 | | 170 | | 5 | Won't error out | 171 | | 6 | a !@#$%^&*():"_;',./][}{<> z | 172 | | 7 | %{shoes: 19_99, | 173 | """, 174 | %{value: value, "unquoted string key": unquoted} do 175 | case value do 176 | 1 -> 177 | assert unquoted == "foo, bar & baz" 178 | 179 | "2 3 4" -> 180 | assert is_nil(unquoted) 181 | 182 | 5 -> 183 | assert unquoted == "Won't error out" 184 | 185 | 6 -> 186 | assert unquoted == "a !@#$%^&*():\"_;',./][}{<> z" 187 | 188 | 7 -> 189 | assert unquoted == "%{shoes: 19_99," 190 | end 191 | end 192 | 193 | describe "user-provided description" do 194 | param_test "is supported", 195 | """ 196 | | value | test_description | 197 | | 1 | "Lorem ipsum" | 198 | | 2 | "Dolar sit amet" | 199 | | 3 | | 200 | """, 201 | %{value: value, test: ex_unit_test_name} do 202 | case value do 203 | 1 -> 204 | assert ex_unit_test_name == :"test user-provided description is supported - Lorem ipsum" 205 | 206 | 2 -> 207 | assert ex_unit_test_name == :"test user-provided description is supported - Dolar sit amet" 208 | 209 | 3 -> 210 | assert ex_unit_test_name in [ 211 | :"test user-provided description is supported ([value: 3, test_description: nil])", 212 | :"test user-provided description is supported ([test_description: nil, value: 3])" 213 | ] 214 | end 215 | end 216 | 217 | param_test "is supported as test_desc", 218 | """ 219 | | value | test_desc | 220 | | 1 | "Lorem ipsum" | 221 | | 2 | "Dolar sit amet" | 222 | | 3 | | 223 | """, 224 | %{value: value, test: ex_unit_test_name} do 225 | case value do 226 | 1 -> 227 | assert ex_unit_test_name == :"test user-provided description is supported as test_desc - Lorem ipsum" 228 | 229 | 2 -> 230 | assert ex_unit_test_name == :"test user-provided description is supported as test_desc - Dolar sit amet" 231 | 232 | 3 -> 233 | assert ex_unit_test_name in [ 234 | :"test user-provided description is supported as test_desc ([value: 3, test_desc: nil])", 235 | :"test user-provided description is supported as test_desc ([test_desc: nil, value: 3])" 236 | ] 237 | end 238 | end 239 | 240 | param_test "is supported as description", 241 | """ 242 | | value | description | 243 | | 1 | "Lorem ipsum" | 244 | """, 245 | %{test: ex_unit_test_name} do 246 | assert ex_unit_test_name == :"test user-provided description is supported as description - Lorem ipsum" 247 | end 248 | 249 | param_test "is supported as Description", 250 | """ 251 | | value | Description | 252 | | 1 | "Lorem ipsum" | 253 | """, 254 | %{test: ex_unit_test_name} do 255 | assert ex_unit_test_name == :"test user-provided description is supported as Description - Lorem ipsum" 256 | end 257 | 258 | param_test "truncates long descriptions", 259 | """ 260 | | value | Description | 261 | | 1 | "This is an extremely long description which goes over the 255 character limit for atom length, blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah" | 262 | """, 263 | %{test: ex_unit_test_name} do 264 | assert ex_unit_test_name == 265 | :"test user-provided description truncates long descriptions - This is an extremely long description which goes over the 255 character limit for atom length, blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah bl" 266 | end 267 | 268 | param_test "doesn't require quotes", 269 | """ 270 | | value | description | 271 | | 1 | Lorem ipsum | 272 | """, 273 | %{test: ex_unit_test_name} do 274 | assert ex_unit_test_name == 275 | :"test user-provided description doesn't require quotes - Lorem ipsum" 276 | end 277 | end 278 | 279 | @module_examples ParameterizedTest.Parser.parse_examples( 280 | """ 281 | | int_1 | int_2 | 282 | | 99 | 100 | 283 | """, 284 | file: __ENV__.file, 285 | line: __ENV__.line 286 | ) 287 | 288 | param_test "accepts pre-parsed values from ~x sigil", 289 | @module_examples, 290 | %{int_1: int_1, int_2: int_2} do 291 | assert int_1 == 99 292 | assert int_2 == 100 293 | end 294 | 295 | param_test "accepts a list of maps or keyword lists", 296 | [ 297 | [int_1: 99, int_2: 100], 298 | %{int_1: 101, int_2: 102} 299 | ], 300 | %{int_1: int_1, int_2: int_2} do 301 | assert int_1 + 1 == int_2 302 | assert int_1 in [99, 101] 303 | assert int_2 in [100, 102] 304 | end 305 | 306 | test "fails to compile Markdown rows with too few columns" do 307 | assert_raise RuntimeError, fn -> 308 | defmodule FailToEvaluateBadMarkdownTest do 309 | use ExUnit.Case, async: true 310 | 311 | import ParameterizedTest 312 | 313 | param_test "test with invalid markdown", 314 | """ 315 | | spending_by_category | coupon | gets_free_shipping? | 316 | | 317 | """, 318 | %{gets_free_shipping?: gets_free_shipping?} do 319 | assert gets_free_shipping? 320 | end 321 | end 322 | end 323 | end 324 | 325 | test "fails to compile when the keys in a handrolled list don't all match" do 326 | assert_raise RuntimeError, fn -> 327 | defmodule FailToEvaluateMismatchedKeys do 328 | @moduledoc false 329 | use ExUnit.Case, async: true 330 | 331 | import ParameterizedTest 332 | 333 | param_test "mismatched keys", 334 | [ 335 | [int_1: 99, int_2: 100], 336 | %{int_1: 101} 337 | ], 338 | %{int_1: int_1, int_2: int_2} do 339 | assert int_1 + 1 == int_2 340 | assert int_1 in [99, 101] 341 | assert int_2 in [100, 102] 342 | end 343 | end 344 | end 345 | end 346 | 347 | @tag skip: true 348 | param_test "applies tags to all parameterized tests", 349 | """ 350 | | text | url | 351 | |----------|----------------------| 352 | | "GitHub" | "https://github.com" | 353 | | "Google" | "https://google.com" | 354 | """ do 355 | flunk("This test should not run") 356 | end 357 | 358 | param_test "applies param_test: true to all parameterized tests", 359 | """ 360 | | text | url | 361 | |----------|----------------------| 362 | | "GitHub" | "https://github.com" | 363 | | "Google" | "https://google.com" | 364 | """, 365 | context do 366 | assert context[:param_test] == true 367 | end 368 | end 369 | 370 | defmodule ParameterizedTestTest.WallabyTest do 371 | use ExUnit.Case, async: true 372 | 373 | import ParameterizedTest 374 | 375 | param_feature "supports Wallaby tests", 376 | """ 377 | | text | url | 378 | |----------|----------------------| 379 | | "GitHub" | "https://github.com" | 380 | | "Google" | "https://google.com" | 381 | """, 382 | %{session: session, text: text, url: url} do 383 | session 384 | |> visit(url) 385 | |> assert_has(Wallaby.Query.text(text, minimum: 1)) 386 | end 387 | 388 | param_feature "applies param_feature: true to all parameterized tests", 389 | """ 390 | | text | url | 391 | |----------|----------------------| 392 | | "GitHub" | "https://github.com" | 393 | | "Google" | "https://google.com" | 394 | """, 395 | context do 396 | assert context[:param_test] == true 397 | end 398 | end 399 | -------------------------------------------------------------------------------- /test/readme_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ReadmeTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ParameterizedTest 5 | 6 | doctest ParameterizedTest, import: true 7 | 8 | defmodule Posts do 9 | @moduledoc false 10 | def can_view?(user) do 11 | user[:permissions] in [:admin, :editor, :viewer] 12 | end 13 | 14 | def can_edit?(user) do 15 | user[:permissions] in [:admin, :editor] 16 | end 17 | end 18 | 19 | setup context do 20 | # context.permissions gets set by the param_test below 21 | permissions = Map.get(context, :permissions, nil) 22 | %{user: %{permissions: permissions}} 23 | end 24 | 25 | param_test "users with at least editor permissions can edit posts", 26 | """ 27 | | permissions | can_edit? | description | 28 | |-------------|-----------|---------------------------------| 29 | | :admin | true | Admins have max permissions | 30 | | :editor | true | Editors can edit (of course!) | 31 | | :viewer | false | Viewers are read-only | 32 | | nil | false | Anonymous viewers are read-only | 33 | """, 34 | %{user: user, permissions: permissions, can_edit?: can_edit?} do 35 | assert Posts.can_edit?(user) == can_edit?, "#{permissions} permissions should grant edit rights" 36 | end 37 | 38 | for {permissions, can_edit?, description} <- [ 39 | {:admin, true, "Admins have max permissions"}, 40 | {:editor, true, "Editors can edit (of course!)"}, 41 | {:viewer, false, "Viewers are read-only"}, 42 | {nil, false, "Anonymous viewers are read-only"} 43 | ] do 44 | @permissions permissions 45 | @can_edit? can_edit? 46 | @description description 47 | 48 | @tag permissions: @permissions 49 | @tag can_edit?: @can_edit? 50 | @tag description: @description 51 | test "users with at least editor permissions can edit posts — #{@description}", %{user: user} do 52 | assert Posts.can_edit?(user) == @can_edit? 53 | end 54 | end 55 | 56 | defmodule ShippingCalculator do 57 | @moduledoc false 58 | def calculate(total_cents_spent, coupon) when is_number(total_cents_spent) do 59 | if total_cents_spent >= 99 * 100 or coupon == "FREE_SHIP" do 60 | 0 61 | else 62 | 5_00 63 | end 64 | end 65 | 66 | def calculate(spending_by_category, coupon) when is_map(spending_by_category) do 67 | bought_socks? = Map.get(spending_by_category, :socks, 0) > 0 68 | 69 | total_spent = 70 | spending_by_category 71 | |> Map.values() 72 | |> Enum.sum() 73 | 74 | if bought_socks? or total_spent > 10_000 or coupon == "FREE_SHIP" do 75 | 0 76 | else 77 | 5_00 78 | end 79 | end 80 | end 81 | 82 | param_test "grants free shipping based on the marketing site's stated policy", 83 | """ 84 | | spending_by_category | coupon | ships_free? | description | 85 | |-------------------------------|-------------|-------------|------------------| 86 | | %{shoes: 19_99, pants: 29_99} | | false | Spent too little | 87 | | %{shoes: 59_99, pants: 49_99} | | true | Spent over $100 | 88 | | %{socks: 10_99} | | true | Socks ship free | 89 | | %{pants: 1_99} | "FREE_SHIP" | true | Correct coupon | 90 | | %{pants: 1_99} | "FOO" | false | Incorrect coupon | 91 | """, 92 | %{ 93 | spending_by_category: spending_by_category, 94 | coupon: coupon, 95 | ships_free?: ships_free? 96 | } do 97 | shipping_cost = ShippingCalculator.calculate(spending_by_category, coupon) 98 | 99 | if ships_free? do 100 | assert shipping_cost == 0 101 | else 102 | assert shipping_cost > 0 103 | end 104 | end 105 | 106 | param_test "grants free shipping for spending $99 or more, or with coupon FREE_SHIP", 107 | """ 108 | | total_cents | coupon | free? | description | 109 | | ----------- | ----------- | ----- | --------------------------- | 110 | | 98_99 | | false | Spent too little | 111 | | 99_00 | | true | Min for free shipping | 112 | | 99_01 | | true | Spent more than the minimum | 113 | | 1_00 | "FREE_SHIP" | true | Had the right coupon | 114 | | 1_00 | "FOO" | false | Unrecognized coupon | 115 | """, 116 | %{total_cents: total_cents, coupon: coupon, free?: gets_free_shipping?} do 117 | shipping_cost = ShippingCalculator.calculate(total_cents, coupon) 118 | free_shipping? = shipping_cost == 0 119 | assert free_shipping? == gets_free_shipping? 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | run_wallaby_tests? = Enum.member?(ExUnit.configuration()[:include], :feature) 2 | 3 | if run_wallaby_tests? do 4 | {:ok, _} = Application.ensure_all_started(:wallaby) 5 | end 6 | 7 | excluded_tags = [:integration, :local_integration, :feature, :todo, :failure_with_backtrace] 8 | ExUnit.start(exclude: excluded_tags, max_cases: System.schedulers_online() * 2) 9 | --------------------------------------------------------------------------------