Skip to content
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

Replace unittest assert methods by assert statement #5386

Merged
merged 17 commits into from
Aug 19, 2024

Conversation

snejus
Copy link
Member

@snejus snejus commented Aug 12, 2024

... continuing the test suite migration to pytest (#5361), this PR replaces unittest method assertions with assert statement.

See the table below for a list of the replaced methods and their counts. This should help clarify the size of the pull request 😆.

To keep things simple for the review, I have replaced each method in its own commit. This
way, things should be easy to follow if you step through the commits one by one. 🙂

method count
assertEqual 1747
assertIn 200
assertTrue 149
assertIsNone 87
assertFalse 85
assertRaises 69
assertNotIn 64
assertNotEqual 39
assertIsNotNone 35
assertIsInstance 35
assertLessEqual 23
assertGreater 15
assertLess 14
assertGreaterEqual 14
assertAlmostEqual 9
assertRaisesRegex 4
assertCountEqual 2
assertListEqual 1

❗ Note that this only replaces methods provided by unittest by default.
Custom assertion method replacements (like assertExists will be addressed in a separate PR).

@snejus snejus self-assigned this Aug 12, 2024
@snejus snejus requested a review from bal-e August 12, 2024 08:57
Copy link

Thank you for the PR! The changelog has not been updated, so here is a friendly reminder to check if you need to add an entry.

@snejus snejus requested a review from Serene-Arc August 12, 2024 08:58
@Serene-Arc
Copy link
Contributor

For a PR like this, if it's done with like a grep and the tests still pass, I don't think it requires more review than a sanity check. Is that what you did, or did you go through by hand?

@snejus
Copy link
Member Author

snejus commented Aug 15, 2024

For a PR like this, if it's done with like a grep and the tests still pass, I don't think it requires more review than a sanity check. Is that what you did, or did you go through by hand?

Most of it was done using a several sed search and replace patterns (see below). Then I later discovered a tool unittest2pytest which I used to double-check my replacements.

I did review the diff manually though since it failed to replace some of the more complicated assertions where I saw the tests fail.

oneline_python() {
  # args: <python-file> ...
  # info: _unformat given .py files: remove newlines from each statement
  sed -zr '
    s/(^|\n) *# [^\n]*//g
    s/  # [^\n]*//g
    s/,?\n\s*([]}).]+)/\1/g
    s/\n\s+(\b(and|or)\b|==)/ \1/g
    s/,\s*\n\s+/, /g
    s/"\s*\n\s+(%|f['\''"])/" \1/g
    s/([[{(])\s*\n\s+/\1/g
    s/(["'\''])\n\s*(["'\''+])/\1 \2/g
    s/\n\s*( \+)/\1/g
    s/(\[[^]\n]+)\n\s*( for)/\1\2/g
  ' $@ |
    tr '\000' '\n'
}

gdfd() {
  # args: <git-args> <pattern>
  # info: show hunks that match the _<pattern>
  local argidx
  argidx=${@[(rI)[^-]*]}

  git diff -U1 ${@[1, $(( argidx - 1 ))]} |
    grepdiff --output-matching=hunk --only-match=mod ${@[$argidx,-1]} | {
      if [[ -t 1 ]]; then
        diff-so-fancy
      else
        cat
      fi
    }
}

unittest2pytest() {
  # args: <python-file> ...
  # info: convert _unittest assertions to _assert statements
  local file
  local contents
  # black -C --line-length=100000 $@
  for file in $@; do
    oneline=$(oneline_python $file)
    print -R $oneline > $file
    gdfd -U0 $file '  # ' | git apply -R --unidiff-zero --allow-empty
    sed -r '
      /def |assert(Raises|Is(Ins|File|Dir)|Counts|(Not)?(Exist|InResult)|EqualTimes|AlbumImport|Perms|Contains|(No)?File|Item|Excludes|Negation|LyricsCon)/!{
        /[a-z]+.assert[A-Z]/{
          s/\(/ /
          s/\)(  #|$)/\1/
          s/msg=//
          s/[a-z._]*assertTrue (([^()]|\([^)]*\))+)/assert \1/
          s/[a-z._]*assertFalse (([^()]|\([^)]*\))+)/assert not \1/
          s/[a-z._]*assertIsNone (.*)(, |$)/assert \1 is None/
          s/[a-z._]*assertIsNotNone (.*)(, |$)/assert \1 is not None/
          s/[a-z._]*assert(List)?Equal (([^,]|\([^)]*\)|\{[^}]*\}|\[[^\]]*\])+), (#[^@]+@ )?(([^,]|\([^)]*\)|\[[^]]*\])+)/assert \2 == \5/
          s/[a-z._]*assertNotIn ([^,]+), (.*)$/assert \1 not in \2/
          s/[a-z._]*assertIn ([^,]+), (.*)$/assert \1 in \2/
          s/[a-z._]*assertLess ([^,]+), (([^()]|\([^)]*\))+)$/assert \1 < \2/
          s/[a-z._]*assertLessEqual ([^,]+), (([^()]|\([^)]*\))+)$/assert \1 <= \2/
          s/[a-z._]*assertGreaterEqual ([^,]+), (.*)$/assert \1 >= \2/
          s/[a-z._]*assertGreater ([^,]+), (.*)$/assert \1 > \2/
          s/[a-z._]*assertNotEqual ([^,]+), (.*)$/assert \1 != \2/
          # s/[a-z._]*assertExists ([^)]+)/assert Path(\1).exists()/
          # s/[a-z._]*assertNotExists ([^)]+)/assert not Path(\1).exists()/
          /[a-z._]*assertAlmostEqual ([^,]+), ([^,)]+(, (places|delta)=[0-9]+)?)/{
            s//assert \1 == pytest.approx(\2)/
            s/places=/rel=1e-/
            s/delta=/abs=/
          }
          # s/[a-z._]*assertEqualTimes ([^,]+), ([^,]+)/assert \1 == pytest.approx(\2, rel=1e-4)/
          # s/[a-z._]*assertInResult ([^,]+), (.*)/assert \1.id in {i.id for i in \2}/
          # s/[a-z._]*assertNotInResult ([^,]+), (.*)/assert \1.id not in {i.id for i in \2}/
        }
      }
      s/[a-z._]*assertRaisesRegex(\([^,]+, )/pytest.raises\1match=/
      /[a-z._]*assertRaises/{
        s//pytest.raises/
        s/^(( +)([a-z][^,]+)), ([^,]+)(, ([^)]+))?\)$/\2with \3):\n\2    \4(\6)/
      }
      s/[a-z._]*assertIsInstance/assert isinstance/
    ' -i $file
  done
  poe format $@
}

@Serene-Arc
Copy link
Contributor

In that case I'll give all of it a quick lookover.

Copy link
Member

@bal-e bal-e left a comment

Choose a reason for hiding this comment

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

Great job, @snejus!

UserError,
msg="Advanced rewrites must have at least one replacement",
match="Advanced rewrites must have at least one replacement",
Copy link
Member

Choose a reason for hiding this comment

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

If the exception message should be exactly this string, can you add ^$ around it? Apparently this argument is passed to re.search() which will check for a substring rather than an exact match.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, this is the only thing that I found. Everything else is equivalent to the previous tests as far as I can see.

Copy link
Member Author

Choose a reason for hiding this comment

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

Do you reckon we should be asserting the entire exception message?

I tend to assert only the relevant part in the exception message, for example

from contextlib import ExitStack as does_not_raise

import pytest
from django.core.management import call_command
from django.core.management.base import CommandError


def error(message_pattern: str):
    return pytest.raises(CommandError, match=message_pattern)


@pytest.mark.parametrize(
    "ids, expected_error",
    (
        ([], error(r"required: ids"), 0),
        ([0, 1], error(r".*do not exist"), 0),
        ([1, 9], error(r"already have request"), 0),
        ([1, 2], does_not_raise(), 2),
    ),
)
def test_create_leave_requests_for_leave(ids, expected_error):
    with expected_error:
        call_command("my_command", *map(str, ids))

this allows the rest of the wording in the message to change. For example, if someone corrects the spelling in the rest of the message, tests won't fail!

Copy link
Member

Choose a reason for hiding this comment

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

If there's a spelling mistake in the rest of the message, it should be caught by the tests. If you know what the entire error message should be, and it's at least consistent across platforms, then I think it is best to check for it.

(Localization of user-facing error messages is a different story, one I don't think beets is in any position to think about right now.)

Copy link
Member Author

Choose a reason for hiding this comment

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

Localisation would indeed be an interesting issue to tackle, but like you said beets it's in no position to achieve it soon.

The situation I am talking about is something similar to my changes in autobpm, for example:

image

If we had two functionally equivalent assertions

assert error == "LibsndfileError: failed to load /a/b/c.mp3"
assert "failed to load" in error.lower()

Even if my change kept the relevant information, the first assertion would have started failing, while the second one would have passed.

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm merging this in but keeping this thread open for the discussion 😄

test/plugins/test_beatport.py Outdated Show resolved Hide resolved
@snejus snejus force-pushed the replace-unittest-assert-methods branch from 05cbc54 to 4b69b49 Compare August 16, 2024 16:47
@snejus snejus merged commit 38a26af into master Aug 19, 2024
12 checks passed
@snejus snejus deleted the replace-unittest-assert-methods branch August 19, 2024 07:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants