Skip to content

Commit a4c14dd

Browse files
ad-mAdam Dobrawyclaude
authored
Switch from nose2 to pytest, add local unit tests (#116) (#116)
- Replace nose2 with pytest as test runner - Tag all existing E2E tests with @pytest.mark.e2e - Add 50 fast unit tests for tasks, exceptions, and client/job logic - Separate CI: unit tests on every push, E2E tests via make test_e2e - Delete unittest.cfg, add pyproject.toml with pytest config Co-authored-by: Adam Dobrawy <naczelnik@jawne.info.pl> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4a4dd76 commit a4c14dd

11 files changed

Lines changed: 585 additions & 22 deletions

File tree

.github/workflows/e2e.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jobs:
2727
- uses: nanasess/setup-chromedriver@v2
2828

2929
- name: Run integration tests
30-
run: make test
30+
run: make test_e2e
3131
env:
3232
KEY: ${{ secrets.anticaptcha_key }}
3333
PROXY_URL: "${{ secrets.proxy_url }}"

Makefile

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
CHROMEDRIVER_VERSION=99.0.4844.17
22
CHROMEDRIVER_DIR=${PWD}/geckodriver
33

4-
.PHONY: lint fmt build docs install test gecko
4+
.PHONY: lint fmt build docs install test test_e2e gecko
55

66
build:
77
python setup.py sdist bdist_wheel
@@ -25,7 +25,10 @@ gecko:
2525
rm ${CHROMEDRIVER_DIR}/chromedriver_linux64.zip
2626

2727
test:
28-
PATH=$$PWD/geckodriver:$$PATH nose2 --verbose
28+
pytest
29+
30+
test_e2e:
31+
PATH=$$PWD/geckodriver:$$PATH pytest -m e2e --override-ini="addopts="
2932

3033
clean:
3134
rm -r build geckodriver

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[tool.pytest.ini_options]
2+
testpaths = ["tests"]
3+
markers = [
4+
"e2e: end-to-end tests requiring API keys and network access",
5+
]
6+
addopts = "-m 'not e2e'"

setup.cfg

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,3 @@ current_version = 0.4.2
33
commit = True
44
tag = True
55
tag_name = {new_version}
6-
7-
[nosetests]
8-
process-timeout = 600

setup.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,11 @@
77
long_description = f.read()
88

99

10-
tests_deps = ["retry", "nose2", "selenium"]
10+
tests_deps = ["retry", "pytest", "selenium"]
1111

1212
extras = {"tests": tests_deps, "docs": "sphinx"}
1313

1414
setup(
15-
test_suite="nose2.collector.collector",
1615
name="python-anticaptcha",
1716
description="Client library for solve captchas with Anticaptcha.com support.",
1817
long_description=long_description,
@@ -39,6 +38,5 @@
3938
keywords="recaptcha captcha development",
4039
packages=["python_anticaptcha"],
4140
install_requires=["requests"],
42-
tests_require=tests_deps,
4341
extras_require=extras,
4442
)

tests/test_base.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
from unittest.mock import patch, MagicMock
2+
import pytest
3+
4+
from python_anticaptcha.base import AnticaptchaClient, Job, SLEEP_EVERY_CHECK_FINISHED
5+
from python_anticaptcha.exceptions import AnticaptchaException
6+
7+
8+
class TestAnticaptchaClientInit:
9+
def test_https_url(self):
10+
client = AnticaptchaClient("key123")
11+
assert client.base_url == "https://api.anti-captcha.com/"
12+
assert client.client_key == "key123"
13+
14+
def test_http_url(self):
15+
client = AnticaptchaClient("key123", use_ssl=False)
16+
assert client.base_url == "http://api.anti-captcha.com/"
17+
18+
def test_custom_host(self):
19+
client = AnticaptchaClient("key123", host="custom.host.com")
20+
assert client.base_url == "https://custom.host.com/"
21+
22+
def test_language_pool(self):
23+
client = AnticaptchaClient("key123", language_pool="rn")
24+
assert client.language_pool == "rn"
25+
26+
27+
class TestCheckResponse:
28+
def setup_method(self):
29+
self.client = AnticaptchaClient("key123")
30+
31+
def test_success_passthrough(self):
32+
response = {"errorId": 0, "taskId": 42}
33+
self.client._check_response(response)
34+
35+
def test_error_raises(self):
36+
response = {
37+
"errorId": 1,
38+
"errorCode": "ERROR_KEY_DOES_NOT_EXIST",
39+
"errorDescription": "Account authorization key not found",
40+
}
41+
with pytest.raises(AnticaptchaException) as exc_info:
42+
self.client._check_response(response)
43+
assert exc_info.value.error_id == 1
44+
assert exc_info.value.error_code == "ERROR_KEY_DOES_NOT_EXIST"
45+
46+
def test_error_id_11_appends_ip(self):
47+
response = {
48+
"errorId": 11,
49+
"errorCode": "ERROR_IP_NOT_ALLOWED",
50+
"errorDescription": "IP not allowed",
51+
}
52+
with patch.object(
53+
type(self.client), "client_ip", new_callable=lambda: property(lambda self: "5.6.7.8")
54+
):
55+
with pytest.raises(AnticaptchaException) as exc_info:
56+
self.client._check_response(response)
57+
assert "5.6.7.8" in exc_info.value.error_description
58+
59+
60+
class TestCreateTask:
61+
def test_payload_structure(self):
62+
client = AnticaptchaClient("key123")
63+
mock_task = MagicMock()
64+
mock_task.serialize.return_value = {"type": "NoCaptchaTaskProxyless", "websiteURL": "https://example.com"}
65+
66+
mock_response = MagicMock()
67+
mock_response.json.return_value = {"errorId": 0, "taskId": 99}
68+
69+
with patch.object(client.session, "post", return_value=mock_response) as mock_post:
70+
job = client.createTask(mock_task)
71+
72+
call_kwargs = mock_post.call_args
73+
payload = call_kwargs[1]["json"] if "json" in call_kwargs[1] else call_kwargs.kwargs["json"]
74+
assert payload["clientKey"] == "key123"
75+
assert payload["task"] == {"type": "NoCaptchaTaskProxyless", "websiteURL": "https://example.com"}
76+
assert payload["softId"] == 847
77+
assert payload["languagePool"] == "en"
78+
assert isinstance(job, Job)
79+
assert job.task_id == 99
80+
81+
82+
class TestGetBalance:
83+
def test_returns_balance(self):
84+
client = AnticaptchaClient("key123")
85+
mock_response = MagicMock()
86+
mock_response.json.return_value = {"errorId": 0, "balance": 3.21}
87+
88+
with patch.object(client.session, "post", return_value=mock_response):
89+
balance = client.getBalance()
90+
assert balance == 3.21
91+
92+
93+
class TestJobCheckIsReady:
94+
def test_ready(self):
95+
client = MagicMock()
96+
client.getTaskResult.return_value = {"status": "ready", "solution": {}}
97+
job = Job(client, task_id=1)
98+
assert job.check_is_ready() is True
99+
100+
def test_processing(self):
101+
client = MagicMock()
102+
client.getTaskResult.return_value = {"status": "processing"}
103+
job = Job(client, task_id=1)
104+
assert job.check_is_ready() is False
105+
106+
107+
class TestJobSolutionGetters:
108+
def setup_method(self):
109+
self.client = MagicMock()
110+
self.job = Job(self.client, task_id=1)
111+
self.job._last_result = {
112+
"status": "ready",
113+
"solution": {
114+
"gRecaptchaResponse": "recaptcha-token",
115+
"token": "funcaptcha-token",
116+
"text": "captcha text",
117+
"cellNumbers": [1, 3, 5],
118+
"answers": {"q1": "a1"},
119+
},
120+
}
121+
122+
def test_get_solution_response(self):
123+
assert self.job.get_solution_response() == "recaptcha-token"
124+
125+
def test_get_token_response(self):
126+
assert self.job.get_token_response() == "funcaptcha-token"
127+
128+
def test_get_captcha_text(self):
129+
assert self.job.get_captcha_text() == "captcha text"
130+
131+
def test_get_cells_numbers(self):
132+
assert self.job.get_cells_numbers() == [1, 3, 5]
133+
134+
def test_get_answers(self):
135+
assert self.job.get_answers() == {"q1": "a1"}
136+
137+
def test_get_solution(self):
138+
assert self.job.get_solution() == self.job._last_result["solution"]
139+
140+
141+
class TestJobJoinTimeout:
142+
@patch("python_anticaptcha.base.time.sleep")
143+
def test_timeout_raises(self, mock_sleep):
144+
client = MagicMock()
145+
client.getTaskResult.return_value = {"status": "processing"}
146+
job = Job(client, task_id=1)
147+
with pytest.raises(AnticaptchaException) as exc_info:
148+
job.join(maximum_time=SLEEP_EVERY_CHECK_FINISHED)
149+
assert "exceeded" in str(exc_info.value).lower()

tests/test_examples.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,11 @@
33
from retry import retry
44

55
import os
6+
import pytest
67

78
from python_anticaptcha import AnticatpchaException
89
from contextlib import contextmanager
910

10-
_multiprocess_can_split_ = True
11-
1211

1312
def missing_key(*args, **kwargs):
1413
return skipIf(
@@ -23,6 +22,7 @@ def missing_proxy(*args, **kwargs):
2322
)(*args, **kwargs)
2423

2524

25+
@pytest.mark.e2e
2626
@missing_key
2727
class AntiGateTestCase(TestCase):
2828
@retry(tries=3)
@@ -34,6 +34,7 @@ def test_process_antigate(self):
3434
self.assertIn(key, solution)
3535

3636

37+
@pytest.mark.e2e
3738
@missing_key
3839
@missing_proxy
3940
class FuncaptchaTestCase(TestCase):
@@ -46,6 +47,7 @@ def test_funcaptcha(self):
4647
self.assertIn("Solved!", funcaptcha_request.process())
4748

4849

50+
@pytest.mark.e2e
4951
@missing_key
5052
class RecaptchaRequestTestCase(TestCase):
5153
# Anticaptcha responds is not fully reliable.
@@ -56,6 +58,7 @@ def test_process(self):
5658
self.assertIn(recaptcha_request.EXPECTED_RESULT, recaptcha_request.process())
5759

5860

61+
@pytest.mark.e2e
5962
@missing_key
6063
@skipIf(True, "Anti-captcha unable to provide required score, but we tests via proxy")
6164
class RecaptchaV3ProxylessTestCase(TestCase):
@@ -78,6 +81,7 @@ def open_driver(*args, **kwargs):
7881
driver.quit()
7982

8083

84+
@pytest.mark.e2e
8185
@missing_key
8286
class RecaptchaSeleniumtTestCase(TestCase):
8387
# Anticaptcha responds is not fully reliable.
@@ -98,6 +102,7 @@ def test_process(self):
98102
)
99103

100104

105+
@pytest.mark.e2e
101106
@missing_key
102107
class TextTestCase(TestCase):
103108
def test_process(self):
@@ -106,6 +111,7 @@ def test_process(self):
106111
self.assertEqual(text.process(text.IMAGE).lower(), text.EXPECTED_RESULT.lower())
107112

108113

114+
@pytest.mark.e2e
109115
@missing_key
110116
@skipIf(True, "We testing via proxy for performance reason.")
111117
class HCaptchaTaskProxylessTestCase(TestCase):
@@ -116,6 +122,7 @@ def test_process(self):
116122
self.assertIn(hcaptcha_request.EXPECTED_RESULT, hcaptcha_request.process())
117123

118124

125+
@pytest.mark.e2e
119126
@missing_key
120127
@missing_proxy
121128
class HCaptchaTaskTestCase(TestCase):

tests/test_exceptions.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from python_anticaptcha.exceptions import (
2+
AnticaptchaException,
3+
AnticatpchaException,
4+
InvalidWidthException,
5+
MissingNameException,
6+
)
7+
8+
9+
class TestAnticaptchaException:
10+
def test_attributes(self):
11+
exc = AnticaptchaException(1, "ERROR_KEY", "Some description")
12+
assert exc.error_id == 1
13+
assert exc.error_code == "ERROR_KEY"
14+
assert exc.error_description == "Some description"
15+
16+
def test_str_format(self):
17+
exc = AnticaptchaException(1, "ERROR_KEY", "Some description")
18+
assert str(exc) == "[ERROR_KEY:1]Some description"
19+
20+
def test_typo_alias(self):
21+
assert AnticatpchaException is AnticaptchaException
22+
23+
24+
class TestInvalidWidthException:
25+
def test_message(self):
26+
exc = InvalidWidthException(75)
27+
assert exc.width == 75
28+
assert "75" in str(exc)
29+
assert "100, 50, 33, 25" in str(exc)
30+
assert exc.error_id == "AC-1"
31+
assert exc.error_code == 1
32+
33+
34+
class TestMissingNameException:
35+
def test_message(self):
36+
exc = MissingNameException("MyClass")
37+
assert exc.cls == "MyClass"
38+
assert "MyClass" in str(exc)
39+
assert '__init__(name="X")' in str(exc)
40+
assert 'serialize(name="X")' in str(exc)
41+
assert exc.error_id == "AC-2"
42+
assert exc.error_code == 2

0 commit comments

Comments
 (0)