-
-
Notifications
You must be signed in to change notification settings - Fork 4.4k
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
Fix part of #5134: Increase test coverage for core.controller.skill_editor #5686
Fix part of #5134: Increase test coverage for core.controller.skill_editor #5686
Conversation
Hi @Xuanluong, to some extent the tests in core.controllers.skill_editor are sometimes integration tests of the whole backend, so I think you can set things up without mocking by returning the relevant things from skill_services or user_services? Alternatively, you could use self.swap() to mock results -- it's defined in core.tests.test_utils and there are several examples of it in the codebase. You can do something like "with self.swap(skill_services, 'name_of_function_you_want_to_swap', desired_return_value)" and put the relevant code underneath that header. Hope that helps! |
@seanlip thanks for the tip. I'll try it. |
Codecov Report
@@ Coverage Diff @@
## develop #5686 +/- ##
========================================
Coverage 45.76% 45.76%
========================================
Files 513 513
Lines 29919 29919
Branches 4522 4522
========================================
Hits 13691 13691
Misses 16228 16228 Continue to review full report at Codecov.
|
…ollers.skill_editor
…ollers.skill_editor
Hi @Xuanluong -- 98% is tantalizingly close! Just to check, are you having trouble with the other 2%? If you have specific questions, let us know :) |
@seanlip There're 2 lines of code left to be covered: oppia/core/controllers/skill_editor.py Line 185 in a325d54
This line looks like dead code to me because it can only be reached if So I don't think this line can be covered. I'm working on the other line but haven't had time in the last few days. |
Thanks @Xuanluong! I agree with you that the line you mentioned is dead code and can probably be removed. @aks681 can you confirm this please? |
self.logout() | ||
|
||
|
||
def _get_skill_by_id(*_args, **_kwargs): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't declare these globally -- make them private methods in the class and maybe call them _mock_get_skill_by_id or similar.
Also typically I don't think there are underscores before *args and **kwargs. That said, probably better to use the actual args that match the mocked method. You can prefix each of them by unused_
to silence lint errors (assuming they are actually unused).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Prefixing unused variables only work for positional arguments. For keyword arguments, if we prefix them with unused
, the signature of the mock method will no long match that of the real method. I guess we have to use something like **unused_kwargs
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have made the methods private and prefix unused
for some arguments in the mock methods. Please take a look.
self.assertEqual(json_resp['can_edit_skill_description'], False) | ||
self.logout() | ||
|
||
def test_skill_rights_handler_fails(self): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@seanlip This test aims to cover this line
oppia/core/controllers/skill_editor.py
Line 104 in a325d54
raise self.InvalidInputException( |
I've spent quite a lot of time making the test cover that lines without success. Perhaps you can figure out what is missing in the setup?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@aks681 Can you help me with this issue?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am not sure why that does not work, but can you try @seanlip's comments below about using an invalid skill_id directly and check if that works?
Also, to try troubleshooting, see the docstring for the swap function itself here:
oppia/core/tests/test_utils.py
Line 1100 in a325d54
def swap(self, obj, attr, newvalue): |
Maybe that will help.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@seanlip @aks681
I want to follow up with Sean's suggestion for this test.
I tried this
model = skill_models.SkillRightsModel.get(self.skill_id)
model.delete(self.admin_id, 'Deleting the model.')
before making the GET request. I also put debugging lines in skill_services.get_skill_rights
and it did return None
. However, the debugging line on skill_editor
was not printed out on the screen and the line is still not covered.
One thing worth notice is the return error is still as expected (404). If the line of code that we're concerned with is not reached, where in the code that an exception was raised that lead to the 404 error?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think there are two things going on here.
Firstly, your code might be hitting the decorator (see the comment I just left previously) since you haven't created a skill.
Secondly, the suggestion I have is actually to create a skill the normal way (using skill_services.save_new_skill) and then manually delete the rights model. I think that should (?) yield the desired result.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@seanlip I think this is dead code because the decorator code checks the same thing here
oppia/core/domain/acl_decorators.py
Line 1417 in f3cf204
skill_rights = skill_services.get_skill_rights(skill_id, strict=False) |
Even if we follow your second suggestion, those lines of code in skill_editor.py
would not be reached anyway.
Let's delete the dead code?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep! Definitely, let's delete any dead code.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @Xuanluong! I took a full pass, PTAL. (@aks681 you might want to take a pass too.)
Re the question you had, I think that should be guarded against, although it would only arise in practice due to some error with writing data to the backend. I suggest creating a test that creates a skill, and then delete the rights model manually using functions in the domain or storage layer -- then pinging the controller.
Does that help?
def _mock_get_skill_by_id(self, unused_skill_id, **unused_kwargs): | ||
return None | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Drop extra newline.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
self.get_json(self.url) | ||
# Check GET returns JSON object with can_edit_skill_description set | ||
# to False if the user is not allowed to edit the skill description. | ||
def _get_all_actions(*_args): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_mock_get_all_actions, and make into class-level function (like in BaseSkillEditorControllerTest). Add short docstring explaining what it's doing conceptually.
Even better: perhaps just test the permissions directly (without a mock) for non-admin users.
actions.remove(role_services.ACTION_EDIT_SKILL_DESCRIPTION) | ||
return actions | ||
with self.swap(role_services, 'get_all_actions', _get_all_actions): | ||
json_resp = self.get_json(self.url) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Prefer json_response; try not to use abbreviations.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
|
||
# Check that admins can access the editable skill data. | ||
# Check GET returns 404 when cannot get skill by id. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Typically, the controller tests are integration tests for the server (of a sort). So, instead of mocking here, maybe just do a GET for a skill ID which does not exist?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I just tried that. The test passed but the line below is not covered:
oppia/core/controllers/skill_editor.py
Line 66 in 8f3d11c
if skill is None: |
I use skill_services.get_new_skill_id()
to get a new skill ID. I don't call save_new_skill
so get_skill_by_id
should return None
. However, it seems like the code never reaches the call to get_skill_by_id
in this case. The reason I think so is because change the expected error to 500 so that I can add some debugging lines at several places in the code, e.g. in the body of the get
method. None of those debugging lines. In addition, I got this error message:
WARNING:root:Invalid URL requested: http://localhost/skill_editor/F4ynhfByGQvn
This skill ID should pass the check of require_valid_skill_id
, so I'm suspecting somewhere in the code we're checking if the skill exists even before the method handling the GET request is called.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah! I think you might have found even more dead code, which is great since it means we can remove it.
There's a decorator on the handler which checks the skill beforehand for validity:
https://github.com/oppia/oppia/blob/develop/core/controllers/skill_editor.py#L55
I think that might have a check for validity, so that this one is redundant?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So, do want to keep the mock now to make the coverage 100%?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hm, not totally sure I understand the suggestion. Shouldn't we just delete the dead code instead?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suggest removing the dead code in the other test because the decorator calls get_skill_rights
so the check if skill_rights is None
is already handled by the decorator.
In this thread we're discussing tests in which I mock the call to skill_services.get_skill_by_id
.
In theory, if a skill id doesn't exist, both get_skill_rights
(called by the decorator) and get_skill_by_id
(called by the methods in skill_editor.py
) will return None
.
In practice, I don't know if the statement above will always hold true, or there might be cases in which the call in the decorator doesn't return None
but the calls in skill_editor.py
do, so I would prefer to keep these lines of code:
if skill_id is None:
raise Exception ...
in skill_editor.py
.
If we want to keep the lines of code above and want to have 100% coverage, we would have to mock get_skill_by_id
.
So, my question is which option do we choose:
1/ Remove the if skill_id is None
checks wherever in the code that get_skill_by_id
is used (dead code).
2/ Keep the if skill_id is None
checks and mock get_skill_by_id
in the test to make the coverage 100%
@seanlip ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, so, I think I get it. You're thinking about the case where skill rights is not None, but the skill ID retrieval fails, right? Generally that should not happen, but theoretically it is a possibility since they are two different models.
So, how about doing the reverse of what I suggested for another test? I.e. create the skill the usual way (which causes both a skill model and a skill rights model to be created), then delete the skill model manually. Then you don't need to mock things, and you can still test the line you're referring to.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
return actions | ||
with self.swap(role_services, 'get_all_actions', _get_all_actions): | ||
json_resp = self.get_json(self.url) | ||
self.assertEqual(json_resp['can_edit_skill_description'], False) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For this check to have meaning there should probably be a corresponding one where can_edit_skill_description is asserted to be True.
@@ -96,47 +157,134 @@ def test_editable_skill_handler_put(self): | |||
'new_value': 'New Description' | |||
}] | |||
} | |||
|
|||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Drop extra newline.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
self.assertEqual(response.status_int, 404) | ||
# Check GET returns 404 when cannot get skill by id. | ||
feconf_swap = self.swap(feconf, 'ENABLE_NEW_STRUCTURES', True) | ||
skill_services_swap = self.swap( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same thing, maybe just use an invalid ID
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
Ditto.
with self.swap(feconf, 'ENABLE_NEW_STRUCTURES', False): | ||
self.put_json(self.url, self.put_payload, csrf_token=csrf_token, | ||
expect_errors=True, expected_status_int=404) | ||
# Check PUT returns 404 when cannot get skill by id. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ditto
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
Ditto.
# Check PUT returns 400 when an exception is raised updating the | ||
# skill. | ||
update_skill_swap = self.swap( | ||
skill_services, 'update_skill', self._mock_update_skill) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we name _mock_update_skill in a way that conveys that it raises an exception? Perhaps self._raise_exception might be a good name (since then this line of code conveys more clearly that you're swapping update_skill with a function that raises an exception), but you might have other ideas too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
|
||
def test_skill_publish_handler_fails(self): | ||
|
||
def _publish_skill(*_args): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See note above re naming for clarity. Also maybe extract this into a class-level method.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
@seanlip Yeah, those 2 lines were there before the |
…ollers.skill_editor
…ollers.skill_editor
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM. Thanks @Xuanluong!
This PR is mainly concerned with increasing test coverage for core.controller.skill_editor as part of #5134 (from 60% to
88%98%100%)For the remaining uncovered lines, I don't know how to make them covered without having a way to mock the returned results of functions inskill_services
oruser_services
.I also refactor some code in the test file.
Checklist
python scripts/pre_commit_linter.py
andbash scripts/run_frontend_tests.sh
.