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

Tests for git / github module - adventures in patching and mocking #445

Draft
wants to merge 24 commits into
base: main
Choose a base branch
from

Conversation

lwasser
Copy link

@lwasser lwasser commented Oct 22, 2021

@nkorinek i'm just playing with some of the tests. i've added some notes and todos. we can talk about this more next week but some of the tests weren't running correctly for me locally such as git branc for _call_git. so i'm working through them. but generally i think what you setup is great and will work for us. we just need to think through exactly what we are testing.

NOTES:

  • updates code cov build to ensure pytest-subprocess is there
  • in many places there is a specific github3. exception thrown and we are not capturing the specific error and missing an opportuity to add a user friendly error there.
  • we are using both subprocess and github3.py i looked and don't see that pygithub or github3 have a clone wrapper which is surprising. maybe we can talk about this more or maybe i'm missing something.
    • FROM KAREN these tools only wrap GITHUB API calls NOT command line git commands - the git API is different / separate
  • we have to be careful with subprocess checks because the stout may vary with machine. for me i have a lot of branches locally so test 5 was failing. i changed to git status which i thikn throws a consistent first message of on branch ... but all sub process testing will be a bit tricky because of this.
  • i worked on one test at a time so some are commented out as i haven't gotten to them yet. lots to discuss but i think i understand how this works now.

I also noticed in many places we aren't capturing specific exceptions so we may want to talk about that more next week.


UPDATE
I am not convinced that pytest.subprocess is doing what we want. it seems to work well when the subprocess is directly called but it was actually cloning on my computer. i found a great SO post that i linked to https://stackoverflow.com/questions/25692440/mocking-a-subprocess-call-in-python that uses unittest.mock. i was able to mock things successfully (i think) but need to look at it again with fresh eyes. there is one part i'm not sure about. i then captured the expected return standard out feedback from clone and tested against that. Do we also want to create a fixture (i started this) with a fake git repo and test that the repo was placed in the correct spot? it seems a bit contrived but i did start to create that fixture. We'd then want to use tmp_path fixture to avoid saving any files to a users directory.


Adding this resource
pytest-dev/pytest#4576
For a discussion on mock vs monkeypatch in unittest vs pytest.

@lwasser lwasser changed the title Updates to nathans pr Updates to nathans pr - tests for git / github Oct 22, 2021
@codecov
Copy link

codecov bot commented Oct 22, 2021

Codecov Report

Merging #445 (d994013) into main (4073701) will increase coverage by 10.34%.
The diff coverage is 97.35%.

Impacted file tree graph

@@             Coverage Diff             @@
##             main     #445       +/-   ##
===========================================
+ Coverage   50.44%   60.79%   +10.34%     
===========================================
  Files          22       22               
  Lines        1447     1584      +137     
===========================================
+ Hits          730      963      +233     
+ Misses        717      621       -96     
Flag Coverage Δ
unittests 60.79% <97.35%> (+10.34%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Impacted Files Coverage Δ
abcclassroom/tests/test_config.py 100.00% <ø> (ø)
abcclassroom/tests/test_distribute.py 100.00% <ø> (ø)
abcclassroom/tests/test_github.py 97.53% <97.31%> (-2.47%) ⬇️
abcclassroom/config.py 78.57% <100.00%> (+10.00%) ⬆️
abcclassroom/github.py 72.15% <100.00%> (+42.58%) ⬆️
abcclassroom/distribute.py 0.00% <0.00%> (-35.00%) ⬇️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 4073701...d994013. Read the comment docs.

elif org == "earthlab" and repository == "test-student":
pass
else:
raise Exception
Copy link
Author

Choose a reason for hiding this comment

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

So there is a specific exception raised. we prbably want to capture that but need to decide on what package first.

@lwasser
Copy link
Author

lwasser commented Oct 22, 2021

ALSO: why is code cov looking at coverage for a test file?

# @staticmethod
def create_repository(self, repository):
if repository == "test_repo":
return True
Copy link
Author

Choose a reason for hiding this comment

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

Why is code cov running test coverage on mock objects? do we have to decorate these in some way?


@pytest.fixture()
def example_student_repo():
"""A fixture with an example student repo."""
Copy link
Author

@lwasser lwasser Oct 26, 2021

Choose a reason for hiding this comment

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

this is the start of a fake repo fixture that i can make - here i would init the repo and drop it wherever the test needed it and then we could run checks on it.


@mock.patch("subprocess.run")
def test_clone_repo_pass2(
mock_subproc_run, monkeypatch, example_student_repo, capsys
Copy link
Author

Choose a reason for hiding this comment

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

here i'm using pytest monkeypatch AND mock_subproc_Run from unittest
is that reasonable? i like mock but monkeypatch is easier for me. but mock seems to better handle subprocess stuff

@lwasser lwasser changed the title Updates to nathans pr - tests for git / github Tests for git / github module - adventures in patching and mocking Oct 29, 2021
@@ -25,7 +25,13 @@ def get_github_auth():
"""
yaml = YAML()
try:
with open(op.expanduser("~/.abc-classroom.tokens.yml")) as f:
with open(
op.join(op.expanduser("~"), ".abc-classroom.tokens.yml")
Copy link
Author

Choose a reason for hiding this comment

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

QUESTION: why is this function in config vs github? it doesn't make sense to me here. but i could be convinced otherwise.

Also a few places conflate expand user with creating a path. this makes testing harder because we want to mock out the specific users home directory. so this needs to be this way to ensure we can easily mock.

Just make sure i didn't break other things by doing this.

Copy link
Author

Choose a reason for hiding this comment

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

@kcranston this is a question about module organization. i'm just curious why some of the github helpers are in config. it's worth just considering organization and i'm very open to whatever we decide i just didn't expect to look for the token helper in config given all other functions are in github. @nkorinek open to your thoughts too!!

Copy link
Collaborator

Choose a reason for hiding this comment

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

Only because config.py is where the authorization functions were when I started on the project. I am not opposed to moving all of the git and github stuff into the same place!

Copy link
Author

Choose a reason for hiding this comment

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

hi!! ok wonderful. it may make more sense. i started writing tests and realized i had to write tests for config in the github file atleast initially... so perhaps moving it will be good.

Copy link
Author

@lwasser lwasser Nov 11, 2021

Choose a reason for hiding this comment

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

ok @kcranston you should be able to run the code below. Notice that no matter what i do i can't get to the FileNotFoundError. This is because if there's no standard error it fails at the startswith check because there is no error message. And otherwise it goes to RunTimeError i'm just confused how to hit that last conditional and haven't been able to test on a computer without SSH setup yet.

import subprocess
import abcclassroom.github as github
import unittest.mock as mock

cmd = ["ssh", "-T", "git@github.com"]
# When i try to mock with a side effect of FileNotFound it doesn't seem
# to mock correctly. rather it just runs and says no error so the mock
# isn't working as i think it should
try:
    with mock.patch('subprocess.run') as mock_requests:
        mock_requests.side_effect = FileNotFoundError
        github.check_git_ssh()
        print("no error")
except subprocess.CalledProcessError as e:
    print("called process error")
except FileNotFoundError as e:
    print("File error")

# This gets us to if subprocess_out.startswith("Hi"): via successful authentication
# i just confused myself because why isn't it raises a subprocess error.
# but it is getting to the correct line of code in the function "hi username"
try:
    with mock.patch('subprocess.run') as mock_requests:
        mock_requests.side_effect = subprocess.CalledProcessError(
            returncode=128, cmd=cmd, stderr="Hi")
        github.check_git_ssh()
        print("no error")
except subprocess.CalledProcessError as e:
    print("called process error")
except FileNotFoundError as e:
    print("File error")

# This raises a called process error as we'd expect the function to do
try:
    with mock.patch('subprocess.run') as mock_requests:
        mock_requests.side_effect = subprocess.CalledProcessError(
            returncode=128, cmd=cmd, stderr="Warning: Permanently")
        github.check_git_ssh()
        print("no error")
except subprocess.CalledProcessError as e:
    print("called process error")
except FileNotFoundError as e:
    print("File error")

# This gets us to a runtime error as we would expect it to. 
try:
    with mock.patch('subprocess.run') as mock_requests:
        mock_requests.side_effect = subprocess.CalledProcessError(
        returncode=128, cmd="", stderr="Encountered this error ")
        github.check_git_ssh()
        print("no error")
except RuntimeError as e:
    print("Runtime error raised")
except subprocess.CalledProcessError as e:
    print("called process error")
except FileNotFoundError as e:
    print("File error")

@@ -61,6 +59,9 @@ def get_access_token():
return access_token


# TODO: document this function with parameters and returns but also
# should it provide a user friendly message based upon where it fails or
# doesnt fail?
def check_git_ssh():
"""Tests that ssh access to GitHub is set up correctly on the users
computer.
Copy link
Author

Choose a reason for hiding this comment

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

hey there @kcranston me again. i think i'm getting a better handle on how this works now but i have a question about this function. The comment below says " We ALWAYS get here". This is true - it seems like when you use check=True it it returns an error w a non zero exit code.

My question is - why do we use check=True here? Without it, it just seems to run and return:

Out[6]: CompletedProcess(args=['ssh', '-T', 'git@github.com'], returncode=1, stdout='', stderr="Hi lwasser! You've successfully authenticated, but GitHub does not provide shell access.\n")

is this potentially because i may see different behavior when running at the command line? just trying to understand as i'm writing tests. it's more complex to route through all of these try/except blocks and more complex to test this way but perhaps there is a reason that i just don't understand to force that error to be thrown when using subprocess w/ check=True

Copy link
Collaborator

Choose a reason for hiding this comment

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

This particular case is strange, because the subprocess call always returns a non-zero exit code. So, we need to check the specific error message from git to see what's going wrong. If we re-write with check=False, then I think the _call_git function won't return the git messages (because it won't catch a CalledProcessError).

Copy link
Author

Choose a reason for hiding this comment

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

ahhh so it's because without raising some sort of error, we can't parse what happened.
Oh also i just thought of this - because it's a subprocess call would it also just fail or pass quietly without check=True ? is that essentially what you're saying? i think that makes sense to me if it's running at the command line, how could it catch a failure.

i just tried it with a fake git command and i see it "passed" but failed at the CLI

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yup - you are correct (in both assumptions). We don't want it to fail quietly, and we need to get the git output to parse exactly what goes wrong. (The reason this always fails is that ssh -T git@github.com always returns a non-zero exit code, because github does not actually allow shell access, even if ssh is set up correctly).

Copy link
Author

Choose a reason for hiding this comment

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

ok i understand @kcranston i'll work on testing through all of the try/excep pieces!! this is super helpful!

# a non-zero exit code whether ssh access is set up correctly or
# not. Must check output.
# not. We then parse output to see what was returned.
Copy link
Author

Choose a reason for hiding this comment

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

hey @kcranston i need a bit of advice here. i'm having a hard time triggering a filenotfounderror for testing. it seems to always go to that final else statement when i trigger any type of error. Just wondering how you managed to get to that part of this conditional? any advice is welcome. i will comment out the test that i have for now and move on but am hopeful you have some ideas!

Copy link
Collaborator

Choose a reason for hiding this comment

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

Oh gosh. It's possible that there is a bug here. I recall going through a lot of contortions to get my machine not set up correctly in order to trigger the various failures. Testing this requires uninstallation of open-ssh.

Copy link
Author

Choose a reason for hiding this comment

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

ahhhh ok i think i understand. I think i also found a simpler version of the mock test code that is reproducible that i can share. i will try to pull that together so you can kind of just run it to see what works and doesn't work. i've managed to test the entire function with the exception of that last part. we have to mock it because so many users will already have ssh setup. (i think) but it should work properly on CI because ssh won't be setup there i think

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