From 70e072fb84770ad36ca5dce19d2255597a142ba6 Mon Sep 17 00:00:00 2001 From: Felix Schwarz Date: Sat, 18 Nov 2023 08:07:04 +0100 Subject: [PATCH] use only pytest for testing pytest is clearly the preferred tool for testing in Python. --- .github/workflows/tests.yml | 1 - mjml/lib/tests/dict_merger_test.py | 27 ++- setup.cfg | 3 +- tests/border_parser_test.py | 7 +- tests/custom_components_test.py | 17 +- tests/includes_with_umlauts_test.py | 55 +++--- tests/missing_functionality_test.py | 56 ++---- tests/mj_button_mailto_link_test.py | 46 +++-- tests/mjml2html_test.py | 20 +- tests/upstream_alignment_test.py | 297 ++++++++++++++-------------- 10 files changed, 249 insertions(+), 280 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8b35e5e..705fe54 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,7 +28,6 @@ jobs: - name: Install dependencies run: | pip install -e .[testing] - pip install pytest - name: Run test suite run: | diff --git a/mjml/lib/tests/dict_merger_test.py b/mjml/lib/tests/dict_merger_test.py index b2140ed..9147e03 100644 --- a/mjml/lib/tests/dict_merger_test.py +++ b/mjml/lib/tests/dict_merger_test.py @@ -2,23 +2,20 @@ # Copyright 2015 Felix Schwarz # The source code in this file is licensed under the MIT license. -from pythonic_testcase import * - from ..dict_merger import merge_dicts -class DictMergerTest(PythonicTestCase): - def test_returns_single_dict_unmodified(self): - assert_equals({}, merge_dicts({})) - assert_equals({'bar': 42}, merge_dicts({'bar': 42})) +def test_returns_single_dict_unmodified(): + assert merge_dicts({}) == {} + assert merge_dicts({'bar': 42}) == {'bar': 42} - def test_can_merge_two_dicts_without_modifying_inputs(self): - a = {'a': 1} - b = {'b': 2} - assert_equals({'a': 1, 'b': 2}, merge_dicts(a, b)) +def test_can_merge_two_dicts_without_modifying_inputs(): + a = {'a': 1} + b = {'b': 2} + assert merge_dicts(a, b) == {'a': 1, 'b': 2} - def test_can_merge_three_dicts_without_modifying_inputs(self): - a = {'a': 1} - b = {'b': 2} - c = {'c': 3} - assert_equals({'a': 1, 'b': 2, 'c': 3}, merge_dicts(a, b, c)) +def test_can_merge_three_dicts_without_modifying_inputs(): + a = {'a': 1} + b = {'b': 2} + c = {'c': 3} + assert merge_dicts(a, b, c) == {'a': 1, 'b': 2, 'c': 3} diff --git a/setup.cfg b/setup.cfg index 8ce75c0..e77a6bd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -54,11 +54,10 @@ exclude = [options.extras_require] testing = - ddt FakeFSHelpers HTMLCompare >= 0.3.0 # >= 0.3.0: ability to ignore attribute ordering in HTML lxml - PythonicTestcase + pytest css_inlining = css_inline >= 0.11, < 0.12 # >= 0.11, < 0.12: CSSInliner(inline_style_tags=..., keep_link_tags=..., keep_style_tags=...) diff --git a/tests/border_parser_test.py b/tests/border_parser_test.py index 4fb9d6a..66b1b5b 100644 --- a/tests/border_parser_test.py +++ b/tests/border_parser_test.py @@ -1,9 +1,6 @@ -from unittest import TestCase - from mjml.helpers import borderParser -class BorderParserTest(TestCase): - def test_can_parse_css_none(self): - self.assertEqual(0, borderParser('none')) +def test_can_parse_css_none(): + assert borderParser('none') == 0 diff --git a/tests/custom_components_test.py b/tests/custom_components_test.py index 333c566..43494c8 100644 --- a/tests/custom_components_test.py +++ b/tests/custom_components_test.py @@ -1,6 +1,4 @@ -from unittest import TestCase - from htmlcompare import assert_same_html from mjml import mjml_to_html @@ -33,12 +31,11 @@ def render(self): return f'
***
{content}
***
' -class CustomComponentsTest(TestCase): - def test_custom_components(self): - expected_html = load_expected_html('_custom') - with get_mjml_fp('_custom') as mjml_fp: - result_list = mjml_to_html(mjml_fp, custom_components=[MjTextCustom, MjTextOverride]) +def test_custom_components(): + expected_html = load_expected_html('_custom') + with get_mjml_fp('_custom') as mjml_fp: + result_list = mjml_to_html(mjml_fp, custom_components=[MjTextCustom, MjTextOverride]) - assert not result_list.errors - list_actual_html = result_list.html - assert_same_html(expected_html, list_actual_html, verbose=True) + assert not result_list.errors + list_actual_html = result_list.html + assert_same_html(expected_html, list_actual_html, verbose=True) diff --git a/tests/includes_with_umlauts_test.py b/tests/includes_with_umlauts_test.py index 3230d12..e97bd29 100644 --- a/tests/includes_with_umlauts_test.py +++ b/tests/includes_with_umlauts_test.py @@ -1,33 +1,38 @@ from io import StringIO -from unittest import TestCase +import pytest from schwarz.fakefs_helpers import FakeFS from mjml import mjml_to_html -class IncludesWithUmlautsTest(TestCase): - def test_can_properly_handle_include_umlauts(self): - fs = FakeFS.set_up(test=self) - included_mjml = ( - '' - ' ' - ' äöüß' - ' ' - '' - ) - mjml = ( - '' - ' ' - ' foo bar' - ' ' - ' ' - '' - ) - fs.create_file('footer.mjml', contents=included_mjml.encode('utf8')) - - result = mjml_to_html(StringIO(mjml)) - html = result.html - - assert ('äöüß' in html) +@pytest.fixture +def fs(): + _fs = FakeFS.set_up() + yield _fs + _fs.tear_down() + + +def test_can_properly_handle_include_umlauts(fs): + included_mjml = ( + '' + ' ' + ' äöüß' + ' ' + '' + ) + mjml = ( + '' + ' ' + ' foo bar' + ' ' + ' ' + '' + ) + fs.create_file('footer.mjml', contents=included_mjml.encode('utf8')) + + result = mjml_to_html(StringIO(mjml)) + html = result.html + + assert ('äöüß' in html) diff --git a/tests/missing_functionality_test.py b/tests/missing_functionality_test.py index d7f9aad..e5b11cb 100644 --- a/tests/missing_functionality_test.py +++ b/tests/missing_functionality_test.py @@ -1,8 +1,7 @@ from pathlib import Path -from unittest import TestCase, expectedFailure -from ddt import data as ddt_data, ddt as DataDrivenTestCase +import pytest from htmlcompare import assert_same_html from mjml import mjml_to_html @@ -10,41 +9,18 @@ TESTDATA_DIR = Path(__file__).parent / 'missing_functionality' -def patch_nose1(func): - def _wrapper(test, *args, **kwargs): - _patch_nose1_result(test) - return func(test, *args, **kwargs) - return _wrapper - - -@DataDrivenTestCase -class MissingFeaturesTest(TestCase): - @ddt_data( - ) - @expectedFailure - @patch_nose1 - def test_ensure_same_html(self, test_id): - mjml_filename = f'{test_id}.mjml' - html_filename = f'{test_id}-expected.html' - with (TESTDATA_DIR / html_filename).open('rb') as html_fp: - expected_html = html_fp.read() - - with (TESTDATA_DIR / mjml_filename).open('rb') as mjml_fp: - result = mjml_to_html(mjml_fp) - - assert not result.errors - actual_html = result.html - assert_same_html(expected_html, actual_html, verbose=True) - - -def _patch_nose1_result(test): - # nose's TextTestResult does not support "expected failures" but I still - # like that test runner. Just treat an expected failure like a skipped test. - result = test._outcome.result - if not hasattr(result, 'addExpectedFailure'): - result.addExpectedFailure = result.addSkip - if not hasattr(result, 'addUnexpectedSuccess'): - def _addUnexpectedSuccess(test): - error = (AssertionError, AssertionError('unexpected success'), None) - return result.addFailure(test, error) - result.addUnexpectedSuccess = _addUnexpectedSuccess +# currently there are no tests which are expected to fail +@pytest.mark.parametrize('test_id', []) +@pytest.mark.xfail +def test_missing_functionality(test_id): + mjml_filename = f'{test_id}.mjml' + html_filename = f'{test_id}-expected.html' + with (TESTDATA_DIR / html_filename).open('rb') as html_fp: + expected_html = html_fp.read() + + with (TESTDATA_DIR / mjml_filename).open('rb') as mjml_fp: + result = mjml_to_html(mjml_fp) + + assert not result.errors + actual_html = result.html + assert_same_html(expected_html, actual_html, verbose=True) diff --git a/tests/mj_button_mailto_link_test.py b/tests/mj_button_mailto_link_test.py index a916e22..5841118 100644 --- a/tests/mj_button_mailto_link_test.py +++ b/tests/mj_button_mailto_link_test.py @@ -1,35 +1,33 @@ import re from io import StringIO -from unittest import TestCase import lxml.html from mjml import mjml_to_html -class MjButtonMailtoLinkTest(TestCase): - def test_no_target_for_mailto_links(self): - mjml = ( - '' - ' ' - ' Click me' - ' ' - '' - ) +def test_no_target_for_mailto_links(): + mjml = ( + '' + ' ' + ' Click me' + ' ' + '' + ) - result = mjml_to_html(StringIO(mjml)) - html = result.html - mailto_match = re.search(']*>', html) - start, end = mailto_match.span() - match_str = html[start:end] + result = mjml_to_html(StringIO(mjml)) + html = result.html + mailto_match = re.search(']*>', html) + start, end = mailto_match.span() + match_str = html[start:end] - a_el = lxml.html.fragment_fromstring(match_str) - self.assertEqual('mailto:foo@site.example', a_el.attrib['href']) - target = a_el.attrib.get('target') - # Thunderbird opens a blank page instead of the new message window if - # the contains 'target="_blank"'. - # https://bugzilla.mozilla.org/show_bug.cgi?id=1677248 - # https://bugzilla.mozilla.org/show_bug.cgi?id=1589968 - # https://bugzilla.mozilla.org/show_bug.cgi?id=421310 - assert not target, f'target="{target}"' + a_el = lxml.html.fragment_fromstring(match_str) + assert a_el.attrib['href'] == 'mailto:foo@site.example' + target = a_el.attrib.get('target') + # Thunderbird opens a blank page instead of the new message window if + # the contains 'target="_blank"'. + # https://bugzilla.mozilla.org/show_bug.cgi?id=1677248 + # https://bugzilla.mozilla.org/show_bug.cgi?id=1589968 + # https://bugzilla.mozilla.org/show_bug.cgi?id=421310 + assert not target, f'target="{target}"' diff --git a/tests/mjml2html_test.py b/tests/mjml2html_test.py index 14c76d5..043309a 100644 --- a/tests/mjml2html_test.py +++ b/tests/mjml2html_test.py @@ -1,17 +1,15 @@ from io import StringIO -from unittest import TestCase from mjml import mjml_to_html -class MJML2HTMLTest(TestCase): - def test_can_handle_comments_in_mjml(self): - mjml = ( - '' - ' ' - ' ' - ' ' - '' - ) - mjml_to_html(StringIO(mjml)) +def test_can_handle_comments_in_mjml(): + mjml = ( + '' + ' ' + ' ' + ' ' + '' + ) + mjml_to_html(StringIO(mjml)) diff --git a/tests/upstream_alignment_test.py b/tests/upstream_alignment_test.py index bd16b73..cb64d5b 100644 --- a/tests/upstream_alignment_test.py +++ b/tests/upstream_alignment_test.py @@ -1,156 +1,159 @@ -import sys from json import load as json_load -from unittest import SkipTest, TestCase, skipIf +import pytest from bs4 import BeautifulSoup -from ddt import data as ddt_data, ddt as DataDrivenTestCase from htmlcompare import assert_same_html from mjml import mjml_to_html from mjml.testing_helpers import get_mjml_fp, load_expected_html -@DataDrivenTestCase -class UpstreamAlignmentTest(TestCase): - @ddt_data( - 'minimal', - 'hello-world', - 'html-entities', - 'html-without-closing-tag', - 'button', - 'text_with_html', - 'mj-body-with-background-color', - 'mj-breakpoint', - 'mj-title', - 'mj-style', - 'mj-accordion', - 'mj-attributes', - 'mj-html-attributes', - 'mj-column-with-attributes', - 'mj-group', - 'mj-hero-fixed', - 'mj-hero-fluid', - 'mj-button-with-width', - 'mj-text-with-tail-text', - 'mj-table', - 'mj-head-with-comment', - 'mj-image-with-empty-alt-attribute', - 'mj-image-with-href', - 'mj-section-with-full-width', - 'mj-section-with-css-class', - 'mj-section-with-mj-class', - 'mj-section-with-background-url', - 'mj-section-with-background', - 'mj-font', - 'mj-font-multiple', - 'mj-font-unused', - 'mj-include-body', - 'mj-preview', - 'mj-raw', - 'mj-raw-with-tags', - 'mj-raw-head', - 'mj-raw-head-with-tags', - 'mj-social', - 'mj-spacer', - 'mj-wrapper', - ) - def test_ensure_same_html(self, test_id): - expected_html = load_expected_html(test_id) - with get_mjml_fp(test_id) as mjml_fp: - result = mjml_to_html(mjml_fp) - - assert not result.errors - actual_html = result.html - assert_same_html(expected_html, actual_html, verbose=True) - - @ddt_data('hello-world') - def test_ensure_same_html_from_json(self, test_id): - expected_html = load_expected_html(test_id) - with get_mjml_fp(test_id, json=True) as mjml_json_fp: - result = mjml_to_html(json_load(mjml_json_fp)) - - assert not result.errors - actual_html = result.html - assert_same_html(expected_html, actual_html, verbose=True) - - def test_accepts_also_plain_strings_as_input(self): - test_id = 'hello-world' - expected_html = load_expected_html(test_id) - with get_mjml_fp(test_id) as mjml_fp: - mjml_str = mjml_fp.read().decode('utf8') - result = mjml_to_html(mjml_str) - - assert not result.errors - actual_html = result.html - assert_same_html(expected_html, actual_html, verbose=True) - - @skipIf(sys.version_info < (3, 7), reason='css_inline requires >= python3.7') - def test_can_use_css_inlining(self): - try: - import css_inline # noqa: F401 (unused-import) - except ImportError: - raise SkipTest('"css_inline" not installed') - test_id = 'css-inlining' - expected_html = load_expected_html(test_id) - with get_mjml_fp(test_id) as mjml_fp: - mjml_str = mjml_fp.read().decode('utf8') - result = mjml_to_html(mjml_str) - - assert not result.errors - assert_same_html(expected_html, result.html, verbose=True) - - # The dynamically generated menu key prevents us from just using - # test_ensure_same_html to test mj-navbar - def test_mj_navbar(self): - test_id = 'mj-navbar' - expected_html = load_expected_html(test_id) - with get_mjml_fp(test_id) as mjml_fp: - result = mjml_to_html(mjml_fp) - - assert not result.errors - expected_soup = BeautifulSoup(expected_html, 'html.parser') - actual_soup = BeautifulSoup(result.html, 'html.parser') - - # This key is randomly generated, so we need to manually replace it. - menuKey = actual_soup.find(attrs={'class': 'mj-menu-checkbox'})['id'] - expected_soup.find(attrs={'class': 'mj-menu-checkbox'})['id'] = menuKey - expected_soup.find(attrs={'class': 'mj-menu-label'})['for'] = menuKey - - assert_same_html(str(expected_soup), str(actual_soup), verbose=True) - - # The dynamically generated carousel ID prevents us from just using - # test_ensure_same_html to test mj-carousel - def test_mj_carousel(self): - test_id = 'mj-carousel' - expected_html = load_expected_html(test_id) - with get_mjml_fp(test_id) as mjml_fp: - result = mjml_to_html(mjml_fp) - - assert not result.errors - expected_soup = BeautifulSoup(expected_html, 'html.parser') - actual_soup = BeautifulSoup(result.html, 'html.parser') - - # This ID is randomly generated, so we need to manually replace it. - def _replace_random_radio_class(soup): - _mj_cr_str = 'mj-carousel-radio' - return soup.find(attrs={'class': _mj_cr_str})['name'].replace(f'{_mj_cr_str}-', '') - expected_carousel_id = _replace_random_radio_class(expected_soup) - actual_carousel_id = _replace_random_radio_class(actual_soup) - actual_html = str(actual_soup).replace(actual_carousel_id, expected_carousel_id) - assert_same_html(str(expected_soup), actual_html, verbose=True) - - # htmlcompare is currently unable to detect these kind of - # whitespace differences. - def test_keep_whitespace_before_tag(self): - test_id = 'missing-whitespace-before-tag' - expected_html = load_expected_html(test_id) - with get_mjml_fp(test_id) as mjml_fp: - result = mjml_to_html(mjml_fp) - - assert not result.errors - expected_text = BeautifulSoup(expected_html, 'html.parser').body.get_text().strip() - body_actual = BeautifulSoup(result.html, 'html.parser').body - actual_text = body_actual.get_text().strip() - assert (expected_text == actual_text) - actual_html = (body_actual.select('.mj-column-per-100 div')[0]).renderContents() - assert (b'foo bar.' == actual_html) +TEST_IDS = ( + 'minimal', + 'hello-world', + 'html-entities', + 'html-without-closing-tag', + 'button', + 'text_with_html', + 'mj-body-with-background-color', + 'mj-breakpoint', + 'mj-title', + 'mj-style', + 'mj-accordion', + 'mj-attributes', + 'mj-html-attributes', + 'mj-column-with-attributes', + 'mj-group', + 'mj-hero-fixed', + 'mj-hero-fluid', + 'mj-button-with-width', + 'mj-text-with-tail-text', + 'mj-table', + 'mj-head-with-comment', + 'mj-image-with-empty-alt-attribute', + 'mj-image-with-href', + 'mj-section-with-full-width', + 'mj-section-with-css-class', + 'mj-section-with-mj-class', + 'mj-section-with-background-url', + 'mj-section-with-background', + 'mj-font', + 'mj-font-multiple', + 'mj-font-unused', + 'mj-include-body', + 'mj-preview', + 'mj-raw', + 'mj-raw-with-tags', + 'mj-raw-head', + 'mj-raw-head-with-tags', + 'mj-social', + 'mj-spacer', + 'mj-wrapper', +) + +@pytest.mark.parametrize('test_id', TEST_IDS) +def test_ensure_same_html_as_upstream(test_id): + expected_html = load_expected_html(test_id) + with get_mjml_fp(test_id) as mjml_fp: + result = mjml_to_html(mjml_fp) + + assert not result.errors + actual_html = result.html + assert_same_html(expected_html, actual_html, verbose=True) + + +def test_ensure_same_html_from_json(): + test_id = 'hello-world' + expected_html = load_expected_html(test_id) + with get_mjml_fp(test_id, json=True) as mjml_json_fp: + result = mjml_to_html(json_load(mjml_json_fp)) + + assert not result.errors + actual_html = result.html + assert_same_html(expected_html, actual_html, verbose=True) + + +def test_accepts_also_plain_strings_as_input(): + test_id = 'hello-world' + expected_html = load_expected_html(test_id) + with get_mjml_fp(test_id) as mjml_fp: + mjml_str = mjml_fp.read().decode('utf8') + result = mjml_to_html(mjml_str) + + assert not result.errors + actual_html = result.html + assert_same_html(expected_html, actual_html, verbose=True) + + +def test_can_use_css_inlining(): + try: + import css_inline # noqa: F401 (unused-import) + except ImportError: + pytest.skip('"css_inline" not installed') + test_id = 'css-inlining' + expected_html = load_expected_html(test_id) + with get_mjml_fp(test_id) as mjml_fp: + mjml_str = mjml_fp.read().decode('utf8') + result = mjml_to_html(mjml_str) + + assert not result.errors + assert_same_html(expected_html, result.html, verbose=True) + + +# The dynamically generated menu key prevents us from just using +# test_ensure_same_html to test mj-navbar +def test_mj_navbar(): + test_id = 'mj-navbar' + expected_html = load_expected_html(test_id) + with get_mjml_fp(test_id) as mjml_fp: + result = mjml_to_html(mjml_fp) + + assert not result.errors + expected_soup = BeautifulSoup(expected_html, 'html.parser') + actual_soup = BeautifulSoup(result.html, 'html.parser') + + # This key is randomly generated, so we need to manually replace it. + menuKey = actual_soup.find(attrs={'class': 'mj-menu-checkbox'})['id'] + expected_soup.find(attrs={'class': 'mj-menu-checkbox'})['id'] = menuKey + expected_soup.find(attrs={'class': 'mj-menu-label'})['for'] = menuKey + + assert_same_html(str(expected_soup), str(actual_soup), verbose=True) + + +# The dynamically generated carousel ID prevents us from just using +# test_ensure_same_html to test mj-carousel +def test_mj_carousel(): + test_id = 'mj-carousel' + expected_html = load_expected_html(test_id) + with get_mjml_fp(test_id) as mjml_fp: + result = mjml_to_html(mjml_fp) + + assert not result.errors + expected_soup = BeautifulSoup(expected_html, 'html.parser') + actual_soup = BeautifulSoup(result.html, 'html.parser') + + # This ID is randomly generated, so we need to manually replace it. + def _replace_random_radio_class(soup): + _mj_cr_str = 'mj-carousel-radio' + return soup.find(attrs={'class': _mj_cr_str})['name'].replace(f'{_mj_cr_str}-', '') + expected_carousel_id = _replace_random_radio_class(expected_soup) + actual_carousel_id = _replace_random_radio_class(actual_soup) + actual_html = str(actual_soup).replace(actual_carousel_id, expected_carousel_id) + assert_same_html(str(expected_soup), actual_html, verbose=True) + + +# htmlcompare is currently unable to detect these kind of +# whitespace differences. +def test_keep_whitespace_before_tag(): + test_id = 'missing-whitespace-before-tag' + expected_html = load_expected_html(test_id) + with get_mjml_fp(test_id) as mjml_fp: + result = mjml_to_html(mjml_fp) + + assert not result.errors + expected_text = BeautifulSoup(expected_html, 'html.parser').body.get_text().strip() + body_actual = BeautifulSoup(result.html, 'html.parser').body + actual_text = body_actual.get_text().strip() + assert (expected_text == actual_text) + actual_html = (body_actual.select('.mj-column-per-100 div')[0]).renderContents() + assert (b'foo bar.' == actual_html)