diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0ef25cd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true +end_of_line = lf +charset = utf-8 + +[*.py] +max_line_length = 100 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6dc53bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Created by .ignore support plugin (hsz.mobi) +### VirtualEnv template +# Virtualenv +# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ +.Python +[Bb]in +[Ii]nclude +[Ll]ib +[Ll]ib64 +[Ll]ocal +[Ss]cripts +pyvenv.cfg +.venv +pip-selfcheck.json + +.idea/ +*.egg-info/ +*.whl diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 0000000..1f875e4 --- /dev/null +++ b/CHANGELOG @@ -0,0 +1,5 @@ +# CHANGELOG + +## 1.0.0 - 2018-03-27 +### Added +- Initial publication \ No newline at end of file diff --git a/README.md b/README.md index b9337ab..08c1626 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,88 @@ # dash-snapshot-testing -Use snapshot testing to test Dash components +Use snapshot testing, inspired by Jest snapshot testing, to test [Dash][] components. + +## Inspiration +Testing a long HTML component output for a Dash application is difficult. +It typically requires hardcoding data or setting up a dummy database. +Using snapshot tests that JSON serialize the Dash component output provide another +easy testing layer to ensure that code refactors/changes do not change the +output unexpectedly. + +To learn more about snapshot testing in general, see a much more elaborate explanation from the [Facebook Jest site](https://facebook.github.io/jest/docs/en/snapshot-testing.html) + +## Usage +```python +import dash_html_components as html + +from snapshot_test import DashSnapshotTestCase + + +class MyUnitTestCase(DashSnapshotTestCase): + def test_component(self): + my_component = html.Div([html.P('wow'), html.Span('this works')], id='test-id') + + self.assertSnapshotEqual(my_component, 'my-test-unique-id') +``` + +This outputs/checks this JSON at `__snapshots__/MyUnitTestCase-my-test-unique-id.json`: +```json +{ + "type": "Div", + "props": { + "id": "test-id", + "children": [ + { + "type": "P", + "props": {"children": "wow"}, + "namespace": "dash_html_components" + }, + { + "type": "Span", + "props": {"children": "this works"}, + "namespace": "dash_html_components" + } + ] + }, + "namespace": "dash_html_components" +} +``` + +### Setting a custom `snapshots_dir` for the class +```python +class MyOtherUnitTestCase(DashSnapshotTestCase): + snapshots_dir = '__snapshots_2__' + + def test_component(self): + my_component = html.Div([html.P('wow'), html.Span('another one')], id='test-id') + + self.assertSnapshotEqual(my_component, 'my-test-unique-id') +``` + +This outputs/checks this JSON at `__snapshots_2__/MyOtherUnitTestCase-my-test-unique-id.json`: +```json +{ + "type": "Div", + "props": { + "id": "test-id", + "children": [ + { + "type": "P", + "props": {"children": "wow"}, + "namespace": "dash_html_components" + }, + { + "type": "Span", + "props": {"children": "another one"}, + "namespace": "dash_html_components" + } + ] + }, + "namespace": "dash_html_components" +} +``` + +At its core, this `unittest.TestCase` compares a JSON-serialized Dash component +against a previously stored JSON-serialized Dash component, and checks if the `dict` +objects from `json.loads` are equivalent using `assertEqual`. + +[Dash]: https://github.com/plotly/dash diff --git a/__snapshots_2__/MyOtherUnitTestCase-my-test-unique-id.json b/__snapshots_2__/MyOtherUnitTestCase-my-test-unique-id.json new file mode 100644 index 0000000..8b6049d --- /dev/null +++ b/__snapshots_2__/MyOtherUnitTestCase-my-test-unique-id.json @@ -0,0 +1 @@ +{"type": "Div", "props": {"id": "test-id", "children": [{"type": "P", "props": {"children": "wow"}, "namespace": "dash_html_components"}, {"type": "Span", "props": {"children": "another one"}, "namespace": "dash_html_components"}]}, "namespace": "dash_html_components"} \ No newline at end of file diff --git a/__snapshots__/MyUnitTestCase-my-test-unique-id.json b/__snapshots__/MyUnitTestCase-my-test-unique-id.json new file mode 100644 index 0000000..872d8b5 --- /dev/null +++ b/__snapshots__/MyUnitTestCase-my-test-unique-id.json @@ -0,0 +1 @@ +{"type": "Div", "props": {"id": "test-id", "children": [{"type": "P", "props": {"children": "wow"}, "namespace": "dash_html_components"}, {"type": "Span", "props": {"children": "this works"}, "namespace": "dash_html_components"}]}, "namespace": "dash_html_components"} \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..32ad337 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +-r ./requirements.txt + +dash-html-components +dash-renderer diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6148c8c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +dash>=0.19 +plotly>=2.2.3 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..b7b1ad6 --- /dev/null +++ b/setup.py @@ -0,0 +1,26 @@ +""" +StratoDem Analytics : setup +Principal Author(s) : Eric Linden +Secondary Author(s) : +Description : + +Notes : + +March 27, 2018 +""" + +from setuptools import setup + +setup( + name='dash-snapshot-testing', + version='1.0.0', + author='Michael Clawar, Eric Linden', + author_email='tech@stratodem.com', + packages=['snapshot_test'], + license='(c) 2018 StratoDem Analytics. All rights reserved.', + description='Dash snapshot testing package', + url='https://github.com/StratoDem/dash-snapshot-testing', + install_requires=[ + 'dash>=0.19.0', + 'plotly>=2.2.3', + ]) diff --git a/snapshot_test/__init__.py b/snapshot_test/__init__.py new file mode 100644 index 0000000..aa2b9a0 --- /dev/null +++ b/snapshot_test/__init__.py @@ -0,0 +1,12 @@ +""" +StratoDem Analytics : __init__.py +Principal Author(s) : Michael Clawar +Secondary Author(s) : +Description : + +Notes : + +March 27, 2018 +""" + +from .snapshot_test_case import * diff --git a/snapshot_test/__tests__/__init__.py b/snapshot_test/__tests__/__init__.py new file mode 100644 index 0000000..49c5414 --- /dev/null +++ b/snapshot_test/__tests__/__init__.py @@ -0,0 +1,10 @@ +""" +StratoDem Analytics : __init__.py +Principal Author(s) : Michael Clawar +Secondary Author(s) : +Description : + +Notes : + +March 27, 2018 +""" diff --git a/snapshot_test/__tests__/test_snapshot_test_case.py b/snapshot_test/__tests__/test_snapshot_test_case.py new file mode 100644 index 0000000..b7583ae --- /dev/null +++ b/snapshot_test/__tests__/test_snapshot_test_case.py @@ -0,0 +1,31 @@ +""" +StratoDem Analytics : __test_snapshot_test_case +Principal Author(s) : Michael Clawar +Secondary Author(s) : +Description : + +Notes : + +March 27, 2018 +""" + + +import dash_html_components as html + +from snapshot_test import DashSnapshotTestCase + + +class MyUnitTestCase(DashSnapshotTestCase): + def test_component(self): + my_component = html.Div([html.P('wow'), html.Span('this works')], id='test-id') + + self.assertSnapshotEqual(my_component, 'my-test-unique-id') + + +class MyOtherUnitTestCase(DashSnapshotTestCase): + snapshots_dir = '__snapshots_2__' + + def test_component(self): + my_component = html.Div([html.P('wow'), html.Span('another one')], id='test-id') + + self.assertSnapshotEqual(my_component, 'my-test-unique-id') diff --git a/snapshot_test/snapshot_test_case.py b/snapshot_test/snapshot_test_case.py new file mode 100644 index 0000000..db9f4e5 --- /dev/null +++ b/snapshot_test/snapshot_test_case.py @@ -0,0 +1,122 @@ +""" +StratoDem Analytics : SnapshotTest +Principal Author(s) : Eric Linden +Secondary Author(s) : +Description : + +Notes : + +March 26, 2018 +""" + +import json +import os +import plotly.utils +import unittest + +from dash.development.base_component import Component + + +__all__ = ['DashSnapshotTestCase'] + + +class DashSnapshotTestCase(unittest.TestCase): + snapshots_dir = None + + def assertSnapshotEqual(self, component: Component, file_id: str) -> None: + """ + Tests the supplied component against the specified JSON file snapshot, if it exists. + If the component and the snapshot match, the test passes. If the specified file is not + found, it is created and the test passes. This test will only fail if the file already + exists, and the component-as-JSON does not match the contents of the file. + + Parameters + ---------- + component: Component + The output of a Dash component that will be rendered to the page + file_id: str + A string ID used to distinguish the multiple JSON files that may be used as + part of a single component's test cases + + Returns + ------- + None + """ + assert isinstance(component, Component), 'Component passed in must be Dash Component' + assert isinstance(file_id, str), 'must pass in a file id to use as unique file ID' + + filename = self.__get_filename(file_id=file_id) + + component_json = component.to_plotly_json() + + if os.path.exists(filename): + # Load a dumped JSON for the passed-in component, to ensure matches standard format + expected_dict = json.loads( + json.dumps(component_json, cls=plotly.utils.PlotlyJSONEncoder)) + self.assertEqual(self.__load_snapshot(filename=filename), expected_dict) + else: + # Component did not already exist, so we'll write to the file + with open(filename, 'w') as file: + json.dump(component_json, file, cls=plotly.utils.PlotlyJSONEncoder) + + def __get_filename(self, file_id: str) -> str: + """ + Builds and returns the path for the specific JSON file used in this test. + + Parameters + ---------- + file_id: str + A string ID used to distinguish the multiple JSON files that may be used as + part of a single component's test cases + + Returns + ------- + A string containing the path to the file. + """ + assert isinstance(file_id, str) + + return os.path.join( + self.__get_snapshots_dir(), + '{}-{}.json'.format(self.__class__.__name__, file_id)) + + @staticmethod + def __load_snapshot(filename: str) -> dict: + """ + Opens the JSON file at the specified location and returns its contents in dict form. + + Parameters + ---------- + filename: str + The path to the JSON file + + Returns + ------- + A dict of the JSON file contents. + """ + assert isinstance(filename, str) + + with open(filename, 'r') as f: + return json.load(f) + + @classmethod + def __get_snapshots_dir(cls) -> str: + """ + Checks for the existence of the snapshots directory, and creates it if it is not found. + It then returns the directory path. + + Returns + ------- + A string containing the path of the snapshots directory. + """ + if cls.snapshots_dir is None: + directory = os.path.join(os.curdir, '__snapshots__') + + if not os.path.exists(directory): + os.mkdir(directory) + + return directory + else: + if not os.path.exists(cls.snapshots_dir): + os.mkdir(cls.snapshots_dir) + + return cls.snapshots_dir