diff --git a/integration-tests/macros/test_helpers.sql b/integration-tests/macros/test_helpers.sql index 097e2b3..8a66562 100644 --- a/integration-tests/macros/test_helpers.sql +++ b/integration-tests/macros/test_helpers.sql @@ -17,3 +17,9 @@ select 1 from (select 1) as t where {{ test_report.succeeded }} {% endif %} {% endmacro %} + +{% macro mock_sanitize(a) %} + {{ return('x') }} +{% endmacro %} + + diff --git a/integration-tests/tests/macros/utils_test.sql b/integration-tests/tests/macros/utils_test.sql new file mode 100644 index 0000000..f22909a --- /dev/null +++ b/integration-tests/tests/macros/utils_test.sql @@ -0,0 +1,103 @@ +{{ config(tags=['unit-test', 'macro_test', 'postgres']) }} + +{% call dbt_unit_testing.macro_test('sanitize None raises exception') %} + {# TODO: How can we test expecting macro to raise exception? + # being able to do something like this would be great: + # + # {% call(msg) dbt_unit_testing.assert_raises_compiler_error() %} + # {{ dbt_unit_testing.sanitize(None) }} + # {% endcall %} #} +{% endcall %} + +UNION ALL + +{% call dbt_unit_testing.macro_test('sanitize empty string returns empty string') %} + {{ dbt_unit_testing.assert_equal(dbt_unit_testing.sanitize(''), '') }} +{% endcall %} + +UNION ALL + +{% call dbt_unit_testing.macro_test('sanitize unusual whitespace is replaced by single spaces') %} + {{ dbt_unit_testing.assert_equal( + dbt_unit_testing.sanitize('abc d e\t\tgh\n\t\n ij\rk\fl\vm\u2005n\u2007o'), + 'abc d e gh ij k l m n o') }} +{% endcall %} + +UNION ALL + +{% call dbt_unit_testing.macro_test('sanitize whitespace at the ends is trimmed') %} + {{ dbt_unit_testing.assert_equal( + dbt_unit_testing.sanitize('\t \nfoo\v\r\f '), 'foo') }} +{% endcall %} + + +{# The tests below explore multiple ways that mocking could be implemented. #} + +UNION ALL + +{% call dbt_unit_testing.macro_test('example A: create mocks and remember to clean up at the end') %} + {% set m = dbt_unit_testing.mock_macro(dbt_unit_testing, 'sanitize', mock_fn = mock_sanitize) %} + {{ dbt_unit_testing.assert_equal( + dbt_unit_testing.mock_example('foo'), 'x') }} + {% do m.restore() %} +{% endcall %} + +UNION ALL + +{% call dbt_unit_testing.macro_test('example B: pass mock config in options, test runner cleans up', options={ + 'mocks': [ + (dbt_unit_testing, 'sanitize', mock_sanitize), + ]}) %} + + {{ dbt_unit_testing.assert_equal( + dbt_unit_testing.mock_example('foo'), 'x') }} +{% endcall %} + +UNION ALL + +{% call dbt_unit_testing.macro_test('example C: mock using return_value') %} + {% set m = dbt_unit_testing.mock_macro(dbt_unit_testing, 'sanitize', return_value=' happy ') %} + + {{ dbt_unit_testing.assert_equal( + dbt_unit_testing.mock_example('foo'), ' happy ') }} + + {{ print('Mock called: %s' % m.calls) }} + {% do m.restore() %} +{% endcall %} + +UNION ALL + +{% call dbt_unit_testing.macro_test('example C.2: return_value can be something other than string') %} + {% set m = dbt_unit_testing.mock_macro(dbt_unit_testing, 'sanitize', return_value=[1, 2]) %} + + {{ dbt_unit_testing.assert_equal( + dbt_unit_testing.mock_example('foo'), [1, 2]) }} + + {% do m.restore() %} +{% endcall %} + +UNION ALL + +{% call(t) dbt_unit_testing.macro_test_with_t('example D: using t helper, test runner cleans up') %} + {% do t.mock(dbt_unit_testing, 'sanitize', return_value='ttest') %} + {{ t.assert_equal( + dbt_unit_testing.mock_example('foo'), 'ttest') }} +{% endcall %} + +UNION ALL + +{% call(t) dbt_unit_testing.macro_test_with_t('example E: using t helper, mock using call') %} + {% call(s) t.mock(dbt_unit_testing, 'sanitize') -%} + {%- do print('sanitize called with %s' % (s|trim)) -%} + {{ return({'mock_dict': 42}) }} + {%- endcall %} + {{ dbt_unit_testing.assert_equal( + dbt_unit_testing.mock_example('foo'), {'mock_dict': 42}) }} +{% endcall %} + +UNION ALL + +{% call dbt_unit_testing.macro_test('check that all other tests restored mocks') %} + {{ dbt_unit_testing.assert_equal( + dbt_unit_testing.mock_example('abc'), 'abc') }} +{% endcall %} diff --git a/macros/macro_test.sql b/macros/macro_test.sql new file mode 100644 index 0000000..36537f6 --- /dev/null +++ b/macros/macro_test.sql @@ -0,0 +1,199 @@ +-- Macros for unit testing macros. +-- +-- The macros in this file are intended for testing macros instead of models. +-- +-- Inspiration for assert_* macros was taken from: +-- https://github.com/yu-iskw/dbt-unittest +-- but instead of raising exceptions on failure, the tests print failure +-- messages and feel much like the model tests that are written +-- with dbt_unit_testing.test() + +-- macro_test runs a unit test. +-- +-- Use with a {% call macro_test... %} test body {% endcall %} from .sql files +-- in the tests directory. +-- +-- If the test body returns anything other than white space, it will +-- be considered a failure, and the content will be printed. +-- +-- Tests written this way are executed with the dbt test command. +{% macro macro_test(description='unspecified', options={}) -%} + {# If caller returns anything, it's considered a failure #} + {%- if execute -%} + + {# Set up mocks specified in options. #} + {% set mocks = [] %} + {% for pkg, macro_name, mock_fn in options.get('mocks', []) %} + {% do mocks.append( + dbt_unit_testing.mock_macro(pkg, macro_name, mock_fn=mock_fn)) %} + {% endfor %} + + {# Run the test. #} + {%- set res = caller()|trim -%} + {%- if res -%} + {{ dbt_unit_testing.println('{RED}TEST: {YELLOW}' ~ description) }} + {{ dbt_unit_testing.println('{RED}ERROR: {YELLOW}' ~ res) }} + select 1 as fail + {%- else -%} + {# Test passed. Return a query that returns zero rows #} + select 1 from (select 1) as t where false + {%- endif -%} + + {# Restore mocks. #} + {% for m in mocks %} + {% do m.restore() %} + {% endfor %} + {%- endif -%} +{%- endmacro %} + + +-- An alternative way to write tests. Each test is given a helper object t +-- with methods for creating mocks that can be tracked and cleaned up +-- automatically at the end of each test. +{% macro macro_test_with_t(description='unspecified', options={}) -%} + {# If caller returns anything, it's considered a failure #} + {%- if execute -%} + {# Run the test. It may call t.mock() to set up mocks. #} + {% set t = dbt_unit_testing._new_t(description, options) %} + {%- set res = caller(t)|trim -%} + {%- if res -%} + {{ dbt_unit_testing.println('{RED}TEST: {YELLOW}' ~ description) }} + {{ dbt_unit_testing.println('{RED}ERROR: {YELLOW}' ~ res) }} + select 1 as fail + {%- else -%} + {# Test passed. Return a query that returns zero rows #} + select 1 from (select 1) as t where false + {%- endif -%} + + {# Restore mocks. #} + {% for m in t.mocks %} + {% do m.restore() %} + {% endfor %} + {%- endif -%} +{%- endmacro %} + +{% macro _new_t(description, options) %} + {% set t = { + 'description': description, + 'options': options, + 'assert_true': dbt_unit_testing.assert_true, + 'assert_equal': dbt_unit_testing.assert_equal, + 'mocks': [], + } %} + + {% call(pkg, name, mock_fn=None, return_value=None) dbt_unit_testing.make_func(t, 'mock') %} + {% if caller and not mock_fn %} + {% set mock_fn = caller %} + {% endif %} + {% set m = dbt_unit_testing.mock_macro( + pkg, name, mock_fn=mock_fn, return_value=return_value) %} + {% do t.mocks.append(m) %} + {# TODO: return m when https://github.com/dbt-labs/dbt-core/issues/7144 is fixed. #} + {# {{ return(m) }} #} + {% endcall %} + + {{ return(t) }} +{% endmacro %} + +-- Returns an error message if b is not true. +{% macro assert_true(b, description='') -%} + {%- if b is not true -%} + {{ b }} is not true{{ ': %s' % description if description else '' }} + {%- endif -%} +{%- endmacro %} + +-- Returns an error message if the values are not equal. +{% macro assert_equal(actual, expected, description='') -%} + {%- if actual != expected -%} + Values are not equal + actual: {{ actual }} + expected: {{ expected }} + {{ description }} + {%- endif -%} +{%- endmacro %} + +{# TODO: I really wish we could do something like this, but the approach here + # has one major drawback: execution continues through the code under test + # after it tries to raise an exception. Also, mocking only works if the exception + # is raised from a jinja macro (not if it comes from python code). + # + # -- Returns an error message if caller() does not raise a compiler error. + # {% macro assert_raises_compiler_error() -%} + # {%- set m = dbt_unit_testing.mock_macro( + # exceptions, 'raise_compiler_error', return_value='') -%} + # {%- do caller() -%} + # {%- if m.calls|length == 0 -%} + # Expected exception to be raised. + # No exception was raised. + # {%- endif -%} + # {%- endmacro %} + #} + +-- Mocks the implementation of a macro and returns a mock object. +{% macro mock_macro(package, macro_name, mock_fn=None, return_value=None) %} + {% set m = { + 'package': package, + 'macro_name': macro_name, + 'return_value': return_value, + 'original_fn': package[macro_name], + 'mock_fn': mock_fn, + 'calls': [], + } %} + + {% call dbt_unit_testing.make_func(m, 'restore') %} + {% do m.package.update({m.macro_name: m.original_fn}) %} + {% endcall %} + + {# If mock_fn is given, use it, otherwise, create a mock_fn + # that returns return_value. #} + {% if not mock_fn %} + {%- call dbt_unit_testing.make_func(m, 'mock_fn') -%} + {%- set c = { + 'args': varargs, + 'kwargs': kwargs, + } -%} + {%- do m.calls.append(c) -%} + {# TODO: Why can I return here unaffected by + # https://github.com/dbt-labs/dbt-core/issues/7144 ? + #} + {{ return(m.return_value) }} + {%- endcall -%} + {% endif %} + + {% do package.update({macro_name: m.mock_fn}) %} + + {{ return(m) }} +{% endmacro %} + +{# make_func creates a function inside the given dict, o, with the given name. + # The implementation of the function is taken from the call body. + # + # This is useful for making dicts that feel like objects. + # + # Example: + # + # {%- macro new_square() -%} + # {%- set square = {'length': 0} -%} + # + # {%- call(length) dbt_unit_testing.make_func(square, 'set_length') -%} + # {%- do square.update({'length': length}) -%} + # {%- endcall -%} + # + # {%- call() dbt_unit_testing.make_func(square, 'area') -%} + # {{ (square.length * square.length) }} + # {%- endcall -%} + # + # {% do return(square) %} + # {%- endmacro -%} + # + # {%- set s = new_square() -%} + # {%- do s.set_length(10) -%} + # {{ square.area() }} + #} +{% macro make_func(o, name) %} + {% do o.update({name: caller}) %} +{% endmacro %} + +{% macro mock_example(s) %} + {{ return(dbt_unit_testing.sanitize(s)) }} +{% endmacro %}