Skip to content

Use Common Test for testing when Gradualizer should pass, fail, and its known problems #567

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 20 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,9 @@ eunit: compile-tests
erl $(ERL_OPTS) -noinput -pa ebin -pa test -eval \
'$(erl_run_eunit), halt().'

ct:
@rebar3 ct --label "git: $$(git describe --tags --always) $$(git diff --no-ext-diff --quiet --exit-code || echo '(modified)')"
Copy link
Collaborator

Choose a reason for hiding this comment

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

We made this whole makefile work without rebar3 because of some annoyances we had with it and to get better control of what's happening. If we mix rebar3 with non-rebar3 we'll have files built in various place and a mess in general. I don't like that.

Isn't it fairly straitforward to run ct without rebar3? It's a command line tool.

Add ct to the tests target and to .PHONY.

We should be able to modify the logic we have in erl_cover_run function to have coverage computed on eunit and commontest combined, or only commontest if we port all eunit tests to commontest.

To run a specific suite, we can do something similar to what erlang.mk does, e.g. make ct suite=known_problems_should_pass_SUITE.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We made this whole makefile work without rebar3...

Generally, I think rebar3 is quite mature and I'd happily drop the Makefile, to be honest. though I admit I see some small benefits of using a tailored Makefile.

Isn't it fairly straitforward to run ct without rebar3? It's a command line tool.

It's possible, but the nice CLI output is provided by a custom Rebar3 CT hook, so if we run CT directly, we'll get uglier printouts.

Add ct to the tests target and to .PHONY.

If we're happy with moving completely to CT, I can do that. For now, I considered this a nicer alternative for local development (especially comparing test results across builds), but did not include it in the CI, nor did I remove the original EUnit tests this is based on. In this light, it didn't make sense to run them twice, so the CT variants are not in tests.


cli-tests: bin/gradualizer test/arg.beam
# CLI test cases
# 1. When checking a dir with erl files, erl file names are printed
Expand Down
5 changes: 1 addition & 4 deletions rebar.config
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@
{deps,
[
{proper, {git, "https://github.com/proper-testing/proper.git", {branch, "master"}}}
]},
%% see the maybe expression fail;
%% the VM also needs to be configured to load the module
{erl_opts, [{feature,maybe_expr,enable}]}
]}
]}
]}.

Expand Down
6 changes: 4 additions & 2 deletions src/typechecker.erl
Original file line number Diff line number Diff line change
Expand Up @@ -5746,8 +5746,10 @@ type_check_forms(Forms, Opts) ->
%% a Gradualizer (NOT the checked program!) error.
-spec type_check_form_with_timeout(expr(), [any()], boolean(), env(), [any()]) -> [any()].
type_check_form_with_timeout(Function, Errors, StopOnFirstError, Env, Opts) ->
%% TODO: make FormCheckTimeOut configurable
FormCheckTimeOut = ?form_check_timeout_ms,
FormCheckTimeOut = case lists:keyfind(form_check_timeout_ms, 1, Opts) of
false -> ?form_check_timeout_ms;
{form_check_timeout_ms, MS} -> MS
end,
?verbose(Env, "Spawning async task...~n", []),
Self = self(),
Task = fun () ->
Expand Down
60 changes: 60 additions & 0 deletions test/gradualizer_dynamic_suite.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
-module(gradualizer_dynamic_suite).

-export([reload/1]).

-include_lib("common_test/include/ct.hrl").
-include_lib("stdlib/include/assert.hrl").

reload(Config) ->
Module = ?config(dynamic_suite_module, Config),
?assert(Module /= undefined),
case erlang:function_exported(Module, generated_tests, 0) of
true ->
{ok, Module:generated_tests()};
Comment on lines +9 to +13
Copy link
Collaborator

Choose a reason for hiding this comment

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

What's this test suite doing?

It looks like a lot of magic. I don't like magic. :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

There's a little bit of magic, but let's start from the standard CT conventions. CT treats files matching *_SUITE.erl as test suites, so gradualizer_dynamic_suite.erl is not a test suite, it's a helper file for use in test suites. The actual test suites match the previous tests defined in EUnit:

  • test/known_problems_should_fail_SUITE.erl
  • test/known_problems_should_pass_SUITE.erl
  • test/should_fail_SUITE.erl
  • test/should_pass_SUITE.erl.

The dynamic suite helper exports only one function - gradualizer_dynamic_suite:reload/1 - which generates CT test cases based on passed in config and then dynamically reloads the module it's invoked from. It's idempotent, so calling it once, twice, or 100 times has the same effect. That's where the name comes from - "dynamic suite". The reason for this helper is that, unlike EUnit, CT has no standard way to generate tests dynamically.

The tests are generated for each of the test files defined under the respective should pass/fail/known problems directories.

The most enigmatic part is where to actually invoke gradualizer_dynamic_suite:reload/1 in a client test suite - this is something I had to dive in OTP CT code to figure out. Apparently, *_SUITE:groups/0 is the first function CT calls when running a test suite, so it's the best place to call reload/1 from. Before @xxdavid's remark that listing cases by hand is tedious we could call reload/1 from something more logical, like init_per_suite/1, but if we want to avoid listing the to-be-generated tests manually, we have to play with how CT runs the test suites.

false ->
Path = ?config(dynamic_suite_test_path, Config),
?assert(Path /= undefined),
Forms = get_forms(Module),
FilesForms = map_erl_files(fun (File) ->
make_test_form(Forms, File, Config)
end, Path),
{TestFiles, TestForms} = lists:unzip(FilesForms),
TestNames = [ list_to_atom(filename:basename(File, ".erl")) || File <- TestFiles ],
ct:pal("All tests found under ~s:\n~p\n", [Path, TestNames]),
GeneratedTestsForm = make_generated_tests_form(TestNames),
NewForms = Forms ++ TestForms ++ [GeneratedTestsForm, {eof, 0}],
{ok, _} = merl:compile_and_load(NewForms),
{ok, TestNames}
end.

map_erl_files(Fun, Dir) ->
Files = filelib:wildcard(filename:join(Dir, "*.erl")),
[{filename:basename(File), Fun(File)} || File <- Files].

make_test_form(Forms, File, Config) ->
TestTemplateName = ?config(dynamic_test_template, Config),
?assert(TestTemplateName /= undefined),
TestTemplate = merl:quote("'@Name'(_) -> _@Body."),
{function, _Anno, _Name, 1, Clauses} = lists:keyfind(TestTemplateName, 3, Forms),
[{clause, _, _Args, _Guards, ClauseBodyTemplate}] = Clauses,
TestName = filename:basename(File, ".erl"),
ClauseBody = merl:subst(ClauseBodyTemplate, [{'File', erl_syntax:string(File)}]),
TestEnv = [
{'Name', erl_syntax:atom(TestName)},
{'Body', ClauseBody}
],
erl_syntax:revert(merl:subst(TestTemplate, TestEnv)).

make_generated_tests_form(TestNames) ->
Template = merl:quote("generated_tests() -> _@Body."),
erl_syntax:revert(merl:subst(Template, [{'Body', merl:term(TestNames)}])).

get_forms(Module) ->
ModPath = code:which(Module),
{ok, {Module, [Abst]}} = beam_lib:chunks(ModPath, [abstract_code]),
{abstract_code, {raw_abstract_v1, Forms}} = Abst,
StripEnd = fun
({eof, _}) -> false;
(_) -> true
end,
lists:filter(StripEnd, Forms).
63 changes: 63 additions & 0 deletions test/known_problems_should_fail_SUITE.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
-module(known_problems_should_fail_SUITE).
Copy link
Collaborator

Choose a reason for hiding this comment

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

Did you duplicate the eunit test suites as commontest suite?

I don't want duplicated logic. Delete the eunit test suites that have been ported to commontest.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I did, yes. If we're happy with moving completely to CT, I'll delete the EUnit tests.


-compile([export_all, nowarn_export_all]).

%% EUnit has some handy macros, so let's use it, too
-include_lib("eunit/include/eunit.hrl").

%% Test server callbacks
-export([suite/0,
all/0,
groups/0,
init_per_suite/1, end_per_suite/1]).

suite() ->
[{timetrap, {minutes, 10}}].

all() ->
[{group, all_tests}].

groups() ->
Config = [
{dynamic_suite_module, ?MODULE},
{dynamic_suite_test_path, filename:join(code:lib_dir(gradualizer), "test/known_problems/should_fail")},
{dynamic_test_template, known_problems_should_fail_template}
],
{ok, GeneratedTests} = gradualizer_dynamic_suite:reload(Config),
[{all_tests, [parallel], GeneratedTests}].

init_per_suite(Config) ->
{ok, _} = application:ensure_all_started(gradualizer),
ok = load_prerequisites(code:lib_dir(gradualizer)),
Config.

load_prerequisites(AppBase) ->
%% exhaustive_user_type.erl is referenced by exhaustive_remote_user_type.erl
gradualizer_db:import_erl_files([filename:join(AppBase, "test/should_fail/exhaustive_user_type.erl")]),
ok.

end_per_suite(_Config) ->
ok = application:stop(gradualizer),
ok.

known_problems_should_fail_template(_@File) ->
Result = safe_type_check_file(_@File, [return_errors, {form_check_timeout_ms, 2000}]),
case Result of
crash ->
ok;
Errors ->
ErrorsExceptTimeouts = lists:filter(
fun ({_File, {form_check_timeout, _}}) -> false; (_) -> true end,
Errors),
?assertEqual(0, length(ErrorsExceptTimeouts))
end.

safe_type_check_file(File) ->
safe_type_check_file(File, []).

safe_type_check_file(File, Opts) ->
try
gradualizer:type_check_file(File, Opts)
catch
_:_ -> crash
end.
55 changes: 55 additions & 0 deletions test/known_problems_should_pass_SUITE.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
-module(known_problems_should_pass_SUITE).

-compile([export_all, nowarn_export_all]).

%% EUnit has some handy macros, so let's use it, too
-include_lib("eunit/include/eunit.hrl").

%% Test server callbacks
-export([suite/0,
all/0,
groups/0,
init_per_suite/1, end_per_suite/1]).

suite() ->
[{timetrap, {minutes, 10}}].

all() ->
[{group, all_tests}].

groups() ->
Config = [
{dynamic_suite_module, ?MODULE},
{dynamic_suite_test_path, filename:join(code:lib_dir(gradualizer), "test/known_problems/should_pass")},
{dynamic_test_template, known_problems_should_pass_template}
],
{ok, GeneratedTests} = gradualizer_dynamic_suite:reload(Config),
[{all_tests, [parallel], GeneratedTests}].

init_per_suite(Config) ->
{ok, _} = application:ensure_all_started(gradualizer),
ok = load_prerequisites(code:lib_dir(gradualizer)),
Config.

load_prerequisites(_AppBase) ->
ok.

end_per_suite(_Config) ->
ok = application:stop(gradualizer),
ok.

known_problems_should_pass_template(_@File) ->
{ok, Forms} = gradualizer_file_utils:get_forms_from_erl(_@File, []),
ExpectedErrors = typechecker:number_of_exported_functions(Forms),
ReturnedErrors = length(safe_type_check_file(_@File, [return_errors, {form_check_timeout_ms, 2000}])),
?assertEqual(ExpectedErrors, ReturnedErrors).

safe_type_check_file(File) ->
safe_type_check_file(File, []).

safe_type_check_file(File, Opts) ->
try
gradualizer:type_check_file(File, Opts)
catch
_:_ -> crash
end.
56 changes: 56 additions & 0 deletions test/should_fail_SUITE.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
-module(should_fail_SUITE).

-compile([export_all, nowarn_export_all]).

%% EUnit has some handy macros, so let's use it, too
-include_lib("eunit/include/eunit.hrl").

%% Test server callbacks
-export([suite/0,
all/0,
groups/0,
init_per_suite/1, end_per_suite/1]).

suite() ->
[{timetrap, {minutes, 10}}].

all() ->
[{group, all_tests}].

groups() ->
Config = [
{dynamic_suite_module, ?MODULE},
{dynamic_suite_test_path, filename:join(code:lib_dir(gradualizer), "test/should_fail")},
{dynamic_test_template, should_fail_template}
],
{ok, GeneratedTests} = gradualizer_dynamic_suite:reload(Config),
[{all_tests, [parallel], GeneratedTests}].

init_per_suite(Config) ->
{ok, _} = application:ensure_all_started(gradualizer),
ok = load_prerequisites(code:lib_dir(gradualizer)),
Config.

load_prerequisites(AppBase) ->
%% user_types.erl is referenced by opaque_fail.erl.
%% It is not in the sourcemap of the DB so let's import it manually
gradualizer_db:import_erl_files([filename:join(AppBase, "test/should_pass/user_types.erl")]),
%% exhaustive_user_type.erl is referenced by exhaustive_remote_user_type.erl
gradualizer_db:import_erl_files([filename:join(AppBase, "test/should_fail/exhaustive_user_type.erl")]),
ok.

end_per_suite(_Config) ->
ok = application:stop(gradualizer),
ok.

should_fail_template(_@File) ->
Errors = gradualizer:type_check_file(_@File, [return_errors, {form_check_timeout_ms, 2000}]),
Timeouts = [ E || {_File, {form_check_timeout, _}} = E <- Errors],
?assertEqual(0, length(Timeouts)),
%% Test that error formatting doesn't crash
Opts = [{fmt_location, brief},
{fmt_expr_fun, fun erl_prettypr:format/1}],
lists:foreach(fun({_, Error}) -> gradualizer_fmt:handle_type_error(Error, Opts) end, Errors),
{ok, Forms} = gradualizer_file_utils:get_forms_from_erl(_@File, []),
ExpectedErrors = typechecker:number_of_exported_functions(Forms),
?assertEqual(ExpectedErrors, length(Errors)).
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
-module(module_info).
-module(module_info_pass).

-compile([export_all, nowarn_export_all]).

Expand All @@ -18,4 +18,4 @@ unary_direct() ->
-spec unary_var() -> atom().
unary_var() ->
I = erlang:module_info(module),
I.
I.
48 changes: 48 additions & 0 deletions test/should_pass_SUITE.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
-module(should_pass_SUITE).

-compile([export_all, nowarn_export_all]).

%% EUnit has some handy macros, so let's use it, too
-include_lib("eunit/include/eunit.hrl").

%% Test server callbacks
-export([suite/0,
all/0,
groups/0,
init_per_suite/1, end_per_suite/1]).

suite() ->
[{timetrap, {minutes, 10}}].

all() ->
[{group, all_tests}].

groups() ->
Opts = [
{dynamic_suite_module, ?MODULE},
{dynamic_suite_test_path, filename:join(code:lib_dir(gradualizer), "test/should_pass")},
{dynamic_test_template, should_pass_template}
],
{ok, GeneratedTests} = gradualizer_dynamic_suite:reload(Opts),
[{all_tests, [parallel], GeneratedTests}].

init_per_suite(Config) ->
{ok, _} = application:ensure_all_started(gradualizer),
ok = load_prerequisites(code:lib_dir(gradualizer)),
Config.

load_prerequisites(AppBase) ->
%% user_types.erl is referenced by remote_types.erl and opaque.erl.
%% It is not in the sourcemap of the DB so let's import it manually
gradualizer_db:import_erl_files([filename:join(AppBase, "test/should_pass/user_types.erl")]),
gradualizer_db:import_erl_files([filename:join(AppBase, "test/should_pass/other_module.erl")]),
%% imported.erl references any.erl
gradualizer_db:import_erl_files([filename:join(AppBase, "test/should_pass/any.erl")]),
ok.

end_per_suite(_Config) ->
ok = application:stop(gradualizer),
ok.

should_pass_template(_@File) ->
?assertEqual(ok, gradualizer:type_check_file(_@File, [{form_check_timeout_ms, 2000}])).
Copy link
Collaborator

Choose a reason for hiding this comment

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

The EUnit tests print a Gradualizer's error message describing the type error when a test fails. I find it useful for quickly glancing over what's going on without having to inspect all the failed test files one by one. Do you think it's possible with CT?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It's available in the CT HTML report, but that requires a little bit of clicking in the browser. It might be possible to get in the shell, too, but I'd have to think some more about it.

Copy link
Collaborator

Choose a reason for hiding this comment

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

IIRC, CT supports plain text reports too. I don't know it well, but if it's too complicated, maybe it isn's worth it.

If we run it ourselves instead of relying on rebar3, we'll have better control over it.

Loading