[WIP] Lots of test refactoring (#1042)

* make behave slightly less verbose for use with behave --format progress2
* standardize behave tests
* move tests around to be more behavior driven
* clean up txt file after tests
* add more tests, add more functionality to behave for calling mock editor
* move around behave tests, get rid of regression files
* clean up some code around keyrings
* add more placeholder test scenarios (marked with @todo)
  You can run just these tests with `behave --no-skipped --tags=todo`
* fix "missing_directory" test
  This test was missing the config file it was trying to use. So, it was
  really a very useless, broken test that we absolutely should not have
  approved the PR (#963) for.
* add write tests for each journal type
* update version tests, add new regex match behave step
* add config test outlines
* add journal types to some search tests
* change "basic" config reference to "simple"
* update configs
* add more journal types in search
* fix basic folder journal reference
* add flush output steps to behave, update delete flag tests
* fix failing test with a flush
* update more delete flag tests to include other journal types
* fix file cleanup after failed test with no debug on
* fix password test
* fix DayOne tag sample data, move search/format tag tests, and run them on multiple jrnl types
* added ability to auto-prompt for password for encrypted journals
  Only uses password when prompted, and doesn't get in the way of other
  input prompts. This allows us to run the same scenarios on both
  encrypted journals and other journal types.
* fold encrypted scenarios into the rest of the scenarios where possible
* remove apostrophe that is breaking tests on CI
* add more journal type tests to import feature
* standardize whitespace in behave tests, take out duplicate test
* update handling of cache directories in test suite (easier syntax)
* skip failing YAML exporter emoji test on Windows
* added @todo tags for things that need follow-up

Co-authored-by: Micah Jerome Ellison <micah.jerome.ellison@gmail.com>
This commit is contained in:
Jonathan Wren 2020-10-17 15:17:38 -07:00 committed by GitHub
parent 1a561a72d9
commit 99f708ca0b
48 changed files with 2382 additions and 1182 deletions

View file

@ -64,7 +64,26 @@ class NoKeyring(keyring.backend.KeyringBackend):
raise keyring.errors.NoKeyringError
# set the keyring for keyring lib
class FailedKeyring(keyring.backend.KeyringBackend):
"""
A keyring that simulates an environment with a keyring that has passwords, but fails
to return them.
"""
priority = 2
keys = defaultdict(dict)
def set_password(self, servicename, username, password):
self.keys[servicename][username] = password
def get_password(self, servicename, username):
raise keyring.errors.NoKeyringError
def delete_password(self, servicename, username):
self.keys[servicename][username] = None
# set a default keyring
keyring.set_keyring(TestKeyring())
@ -93,16 +112,48 @@ def open_journal(journal_name="default"):
return Journal.open_journal(journal_name, config)
def read_value_from_string(string):
if string[0] == "{":
# Handle value being a dictionary
return ast.literal_eval(string)
# Takes strings like "bool:true" or "int:32" and coerces them into proper type
t, value = string.split(":")
value = {"bool": lambda v: v.lower() == "true", "int": int, "str": str}[t](value)
return value
@given('we use the config "{config_file}"')
def set_config(context, config_file):
full_path = os.path.join("features/configs", config_file)
install.CONFIG_FILE_PATH = os.path.abspath(full_path)
if config_file.endswith("yaml"):
if config_file.endswith("yaml") and os.path.exists(full_path):
# Add jrnl version to file for 2.x journals
with open(install.CONFIG_FILE_PATH, "a") as cf:
cf.write("version: {}".format(__version__))
@given('we use the password "{password}" if prompted')
def use_password_forever(context, password):
context.password = password
@given('we use the password "{password}" {num:d} times if prompted')
def use_password(context, password, num=1):
context.password = iter([password] * num)
@given("we have a keyring")
def set_keyring(context):
keyring.set_keyring(TestKeyring())
@given("we do not have a keyring")
def disable_keyring(context):
keyring.core.set_keyring(NoKeyring())
@when('we change directory to "{path}"')
def move_up_dir(context, path):
os.chdir(path)
@ -122,7 +173,7 @@ def open_editor_and_enter(context, method, text=""):
else:
file_method = "r+"
def _mock_editor_function(command):
def _mock_editor(command):
context.editor_command = command
tmpfile = command[-1]
with open(tmpfile, file_method) as f:
@ -130,19 +181,36 @@ def open_editor_and_enter(context, method, text=""):
return tmpfile
if "password" in context:
password = context.password
else:
password = ""
# fmt: off
# see: https://github.com/psf/black/issues/664
with \
patch("subprocess.call", side_effect=_mock_editor_function), \
patch("subprocess.call", side_effect=_mock_editor) as mock_editor, \
patch("getpass.getpass", side_effect=_mock_getpass(password)) as mock_getpass, \
patch("sys.stdin.isatty", return_value=True) \
:
context.editor = mock_editor
context.getpass = mock_getpass
cli(["--edit"])
# fmt: on
@then("the editor should have been called")
@then("the editor should have been called with {num} arguments")
def count_editor_args(context, num):
assert len(context.editor_command) == int(num)
def count_editor_args(context, num=None):
assert context.editor.called
if isinstance(num, int):
assert len(context.editor_command) == int(num)
@then("the editor should not have been called")
def no_editor_called(context, num=None):
assert "editor" not in context or not context.editor.called
@then('one editor argument should be "{arg}"')
@ -156,25 +224,32 @@ def contains_editor_arg(context, arg):
@then('one editor argument should match "{regex}"')
def matches_editor_arg(context, regex):
args = context.editor_command
matches = list(filter(lambda x: re.match(regex, x), args))
matches = list(filter(lambda x: re.search(regex, x), args))
assert (
len(matches) == 1
), f"\nRegex didn't match exactly 1 time:\n{regex}\n{str(args)}"
def _mock_getpass(inputs):
def prompt_return(prompt="Password: "):
print(prompt)
return next(inputs)
def prompt_return(prompt=""):
if type(inputs) == str:
return inputs
try:
return next(inputs)
except StopIteration:
raise KeyboardInterrupt
return prompt_return
def _mock_input(inputs):
def prompt_return(prompt=""):
val = next(inputs)
print(prompt, val)
return val
try:
val = next(inputs)
print(prompt, val)
return val
except StopIteration:
raise KeyboardInterrupt
return prompt_return
@ -192,12 +267,24 @@ def run_with_input(context, command, inputs=""):
args = ushlex(command)[1:]
def _mock_editor(command):
context.editor_command = command
tmpfile = command[-1]
context.editor_file = tmpfile
Path(tmpfile).touch()
if "password" in context:
password = context.password
else:
password = text
# fmt: off
# see: https://github.com/psf/black/issues/664
with \
patch("builtins.input", side_effect=_mock_input(text)) as mock_input, \
patch("getpass.getpass", side_effect=_mock_getpass(text)) as mock_getpass, \
patch("sys.stdin.read", side_effect=text) as mock_read \
patch("getpass.getpass", side_effect=_mock_getpass(password)) as mock_getpass, \
patch("sys.stdin.read", side_effect=text) as mock_read, \
patch("subprocess.call", side_effect=_mock_editor) as mock_editor \
:
try:
cli(args or [])
@ -205,26 +292,56 @@ def run_with_input(context, command, inputs=""):
except SystemExit as e:
context.exit_status = e.code
# at least one of the mocked input methods got called
assert mock_input.called or mock_getpass.called or mock_read.called
# all inputs were used
try:
next(text)
assert False, "Not all inputs were consumed"
except StopIteration:
pass
# put mocks into context so they can be checked later in "then" statements
context.editor = mock_editor
context.input = mock_input
context.getpass = mock_getpass
context.read = mock_read
context.iter_text = text
context.execute_steps('''
Then all input was used
And at least one input method was called
''')
# fmt: on
@then("at least one input method was called")
def inputs_were_called(context):
assert (
context.input.called
or context.getpass.called
or context.read.called
or context.editor.called
)
@then("we should be prompted for a password")
def password_was_called(context):
assert context.getpass.called
@then("we should not be prompted for a password")
def password_was_not_called(context):
assert not context.getpass.called
@then("all input was used")
def all_input_was_used(context):
# all inputs were used (ignore if empty string)
for temp in context.iter_text:
assert "" == temp, "Not all inputs were consumed"
@when('we run "{command}"')
@when('we run "{command}" and pipe')
@when('we run "{command}" and pipe "{text}"')
@when('we run "{command}" with cache directory "{cache_dir}"')
def run(context, command, text="", cache_dir=None):
def run(context, command, text=""):
text = text or context.text or ""
if cache_dir is not None:
cache_dir = os.path.join("features", "cache", cache_dir)
if "cache_dir" in context and context.cache_dir is not None:
cache_dir = os.path.join("features", "cache", context.cache_dir)
command = command.format(cache_dir=cache_dir)
args = ushlex(command)
@ -232,12 +349,23 @@ def run(context, command, text="", cache_dir=None):
def _mock_editor(command):
context.editor_command = command
if "password" in context:
password = context.password
else:
password = iter(text)
try:
with patch("sys.argv", args), patch(
"subprocess.call", side_effect=_mock_editor
), patch("sys.stdin.read", side_effect=lambda: text):
# fmt: off
# see: https://github.com/psf/black/issues/664
with \
patch("sys.argv", args), \
patch("getpass.getpass", side_effect=_mock_getpass(password)), \
patch("subprocess.call", side_effect=_mock_editor), \
patch("sys.stdin.read", side_effect=lambda: text) \
:
cli(args[1:])
context.exit_status = 0
# fmt: on
except SystemExit as e:
context.exit_status = e.code
@ -249,16 +377,11 @@ def load_template(context, filename):
plugins.__exporter_types[exporter.names[0]] = exporter
@when('we set the keychain password of "{journal}" to "{password}"')
def set_keychain(context, journal, password):
@when('we set the keyring password of "{journal}" to "{password}"')
def set_keyring_password(context, journal, password):
keyring.set_password("jrnl", journal, password)
@when("we disable the keychain")
def disable_keychain(context):
keyring.core.set_keyring(NoKeyring())
@then("we should get an error")
def has_error(context):
assert context.exit_status != 0, context.exit_status
@ -269,10 +392,33 @@ def no_error(context):
assert context.exit_status == 0, context.exit_status
@then("we flush the output")
def flush_stdout(context):
context.stdout_capture.truncate(0)
context.stdout_capture.seek(0)
@then("we flush the error output")
def flush_stderr(context):
context.stderr_capture.truncate(0)
context.stderr_capture.seek(0)
@then("we flush all the output")
def flush_all_output(context):
context.execute_steps(
"""
Then we flush the output
Then we flush the error output
"""
)
@then("the output should be")
@then("the output should be empty")
@then('the output should be "{text}"')
def check_output(context, text=None):
text = (text or context.text).strip().splitlines()
text = (text or context.text or "").strip().splitlines()
out = context.stdout_capture.getvalue().strip().splitlines()
assert len(text) == len(out), "Output has {} lines (expected: {})".format(
len(out), len(text)
@ -316,7 +462,27 @@ def check_output_inline(context, text=None, text2=None):
def check_error_output_inline(context, text=None, text2=None):
text = text or context.text
out = context.stderr_capture.getvalue()
assert text in out or text2 in out, text or text2
assert (text and text in out) or (text2 and text2 in out)
@then('the output should match "{regex}"')
@then('the output should match "{regex}" {num} times')
def matches_std_output(context, regex, num=1):
out = context.stdout_capture.getvalue()
matches = re.findall(regex, out)
assert (
matches and len(matches) == num
), f"\nRegex didn't match exactly {num} time(s):\n{regex}\n{str(out)}\n{str(matches)}"
@then('the error output should match "{regex}"')
@then('the error output should match "{regex}" {num} times')
def matches_err_ouput(context, regex, num=1):
out = context.stderr_capture.getvalue()
matches = re.findall(regex, out)
assert (
matches and len(matches) == num
), f"\nRegex didn't match exactly {num} time(s):\n{regex}\n{str(out)}\n{str(matches)}"
@then('the output should not contain "{text}"')
@ -326,6 +492,7 @@ def check_output_not_inline(context, text):
@then('we should see the message "{text}"')
@then('the error output should be "{text}"')
def check_message(context, text):
out = context.stderr_capture.getvalue()
assert text in out, [text, out]
@ -351,33 +518,48 @@ def check_not_journal_content(context, text, journal_name="default"):
assert text not in journal, journal
@then("the journal should not exist")
@then('journal "{journal_name}" should not exist')
def journal_doesnt_exist(context, journal_name="default"):
with open(install.CONFIG_FILE_PATH) as config_file:
config = yaml.load(config_file, Loader=yaml.FullLoader)
config = load_config(install.CONFIG_FILE_PATH)
journal_path = config["journals"][journal_name]
assert not os.path.exists(journal_path)
@then("the journal should exist")
@then('journal "{journal_name}" should exist')
def journal_exists(context, journal_name="default"):
config = load_config(install.CONFIG_FILE_PATH)
journal_path = config["journals"][journal_name]
assert os.path.exists(journal_path)
@then('the config should have "{key}" set to')
@then('the config should have "{key}" set to "{value}"')
@then('the config for journal "{journal}" should have "{key}" set to "{value}"')
def config_var(context, key, value, journal=None):
if not value[0] == "{":
t, value = value.split(":")
value = {"bool": lambda v: v.lower() == "true", "int": int, "str": str}[t](
value
)
else:
# Handle value being a dictionary
value = ast.literal_eval(value)
def config_var(context, key, value="", journal=None):
value = read_value_from_string(value or context.text or "")
config = load_config(install.CONFIG_FILE_PATH)
if journal:
config = config["journals"][journal]
assert key in config
assert config[key] == value
@then('the config for journal "{journal}" should not have "{key}" set')
def config_no_var(context, key, value="", journal=None):
config = load_config(install.CONFIG_FILE_PATH)
if journal:
config = config["journals"][journal]
assert key not in config
@then("the journal should have {number:d} entries")
@then("the journal should have {number:d} entry")
@then('journal "{journal_name}" should have {number:d} entries')
@ -397,21 +579,6 @@ def list_journal_directory(context, journal="default"):
print(os.path.join(root, file))
@then("the Python version warning should appear if our version is below {version}")
def check_python_warning_if_version_low_enough(context, version):
import platform
import packaging.version
if packaging.version.parse(platform.python_version()) < packaging.version.parse(
version
):
out = context.stderr_capture.getvalue()
assert "WARNING: Python versions" in out
else:
assert True
@then("fail")
def debug_fail(context):
assert False

View file

@ -1,6 +1,8 @@
import json
import os
import shutil
import random
import string
from xml.etree import ElementTree
from behave import given
@ -116,23 +118,32 @@ def assert_xml_output_tags(context, expected_tags_json_list):
@given('we create cache directory "{dir_name}"')
def create_directory(context, dir_name):
@given("we create a cache directory")
def create_directory(context, dir_name=None):
if not dir_name:
dir_name = "cache_" + "".join(
random.choices(string.ascii_uppercase + string.digits, k=20)
)
working_dir = os.path.join("features", "cache", dir_name)
if os.path.exists(working_dir):
shutil.rmtree(working_dir)
os.makedirs(working_dir)
context.cache_dir = dir_name
@then('cache directory "{dir_name}" should contain the files')
@then(
'cache directory "{dir_name}" should contain the files {expected_files_json_list}'
)
def assert_dir_contains_files(context, dir_name, expected_files_json_list="[]"):
@then('cache "{dir_name}" should contain the files')
@then('cache "{dir_name}" should contain the files {expected_files_json_list}')
@then("the cache should contain the files")
def assert_dir_contains_files(context, dir_name=None, expected_files_json_list=""):
if not dir_name:
dir_name = context.cache_dir
working_dir = os.path.join("features", "cache", dir_name)
actual_files = os.listdir(working_dir)
expected_files = context.text or expected_files_json_list
expected_files = json.loads(expected_files)
expected_files = expected_files.split("\n")
# sort to deal with inconsistent default file ordering on different OS's
actual_files.sort()
@ -142,7 +153,11 @@ def assert_dir_contains_files(context, dir_name, expected_files_json_list="[]"):
@then('the content of file "{file_path}" in cache directory "{cache_dir}" should be')
def assert_exported_yaml_file_content(context, file_path, cache_dir):
@then('the content of file "{file_path}" in the cache should be')
def assert_exported_yaml_file_content(context, file_path, cache_dir=None):
if not cache_dir:
cache_dir = context.cache_dir
expected_content = context.text.strip().splitlines()
full_file_path = os.path.join("features", "cache", cache_dir, file_path)