Merge pull request #355 from blackms/v3

Add Unit Tests for LinkedInJobManager
This commit is contained in:
Federico 2024-09-11 19:38:24 +02:00 committed by GitHub
commit 5568931234
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 706 additions and 0 deletions

5
pytest.ini Normal file
View file

@ -0,0 +1,5 @@
[pytest]
minversion = 6.0
addopts = --strict-markers --tb=short --cov=src --cov-report=term-missing
testpaths =
tests

View file

@ -24,3 +24,4 @@ jsonschema-specifications==2023.12.1
httpx~=0.27.2
python-dotenv~=1.0.1
PyYAML~=6.0.2
pytest>=8.3.3

View file

@ -76,6 +76,20 @@ class LinkedInEasyApplier:
logger.error("Failed to return to job page after %d attempts. Cannot apply for the job.", max_attempts)
raise Exception(
f"Redirected to LinkedIn Premium page and failed to return after {max_attempts} attempts. Job application aborted.")
def apply_to_job(self, job: Any) -> None:
"""
Starts the process of applying to a job.
:param job: A job object with the job details.
:return: None
"""
logger.debug(f"Applying to job: {job}")
try:
self.job_apply(job)
logger.info(f"Successfully applied to job: {job.title}")
except Exception as e:
logger.error(f"Failed to apply to job: {job.title}, error: {str(e)}")
raise e
def job_apply(self, job: Any):
logger.debug("Starting job application for job: %s", job)

0
tests/__init__.py Normal file
View file

View file

@ -0,0 +1,153 @@
import pytest
from src.job_application_profile import JobApplicationProfile
@pytest.fixture
def valid_yaml():
"""Valid YAML string for initializing JobApplicationProfile."""
return """
self_identification:
gender: Male
pronouns: He/Him
veteran: No
disability: No
ethnicity: Asian
legal_authorization:
eu_work_authorization: "Yes"
us_work_authorization: "Yes"
requires_us_visa: "No"
legally_allowed_to_work_in_us: "Yes"
requires_us_sponsorship: "No"
requires_eu_visa: "No"
legally_allowed_to_work_in_eu: "Yes"
requires_eu_sponsorship: "No"
work_preferences:
remote_work: "Yes"
in_person_work: "No"
open_to_relocation: "Yes"
willing_to_complete_assessments: "Yes"
willing_to_undergo_drug_tests: "Yes"
willing_to_undergo_background_checks: "Yes"
availability:
notice_period: "2 weeks"
salary_expectations:
salary_range_usd: "80000-120000"
"""
@pytest.fixture
def missing_field_yaml():
"""YAML string missing a required field (self_identification)."""
return """
legal_authorization:
eu_work_authorization: "Yes"
us_work_authorization: "Yes"
requires_us_visa: "No"
legally_allowed_to_work_in_us: "Yes"
requires_us_sponsorship: "No"
requires_eu_visa: "No"
legally_allowed_to_work_in_eu: "Yes"
requires_eu_sponsorship: "No"
work_preferences:
remote_work: "Yes"
in_person_work: "No"
open_to_relocation: "Yes"
willing_to_complete_assessments: "Yes"
willing_to_undergo_drug_tests: "Yes"
willing_to_undergo_background_checks: "Yes"
availability:
notice_period: "2 weeks"
salary_expectations:
salary_range_usd: "80000-120000"
"""
@pytest.fixture
def invalid_type_yaml():
"""YAML string with an invalid type for a field."""
return """
self_identification:
gender: Male
pronouns: He/Him
veteran: No
disability: No
ethnicity: Asian
legal_authorization:
eu_work_authorization: "Yes"
us_work_authorization: "Yes"
requires_us_visa: "No"
legally_allowed_to_work_in_us: "Yes"
requires_us_sponsorship: "No"
requires_eu_visa: "No"
legally_allowed_to_work_in_eu: "Yes"
requires_eu_sponsorship: "No"
work_preferences:
remote_work: 12345 # Invalid type, expecting a string
in_person_work: "No"
open_to_relocation: "Yes"
willing_to_complete_assessments: "Yes"
willing_to_undergo_drug_tests: "Yes"
willing_to_undergo_background_checks: "Yes"
availability:
notice_period: "2 weeks"
salary_expectations:
salary_range_usd: "80000-120000"
"""
def test_initialize_with_valid_yaml(valid_yaml):
"""Test initializing JobApplicationProfile with valid YAML."""
profile = JobApplicationProfile(valid_yaml)
# Check that the profile fields are correctly initialized
assert profile.self_identification.gender == "Male"
assert profile.self_identification.pronouns == "He/Him"
assert profile.legal_authorization.eu_work_authorization == "Yes"
assert profile.work_preferences.remote_work == "Yes"
assert profile.availability.notice_period == "2 weeks"
assert profile.salary_expectations.salary_range_usd == "80000-120000"
def test_initialize_with_missing_field(missing_field_yaml):
"""Test initializing JobApplicationProfile with missing required fields."""
with pytest.raises(KeyError) as excinfo:
JobApplicationProfile(missing_field_yaml)
assert "self_identification" in str(excinfo.value)
def test_initialize_with_invalid_yaml():
"""Test initializing JobApplicationProfile with invalid YAML."""
invalid_yaml_str = """
self_identification:
gender: Male
pronouns: He/Him
veteran: No
disability: No
ethnicity: Asian
legal_authorization:
eu_work_authorization: "Yes"
us_work_authorization: "Yes"
requires_us_visa: "No"
legally_allowed_to_work_in_us: "Yes"
requires_us_sponsorship: "No"
requires_eu_visa: "No"
legally_allowed_to_work_in_eu: "Yes"
requires_eu_sponsorship: "No"
work_preferences:
remote_work: "Yes"
in_person_work: "No"
availability:
notice_period: "2 weeks"
salary_expectations:
salary_range_usd: "80000-120000"
""" # Missing fields in work_preferences
with pytest.raises(TypeError):
JobApplicationProfile(invalid_yaml_str)
def test_str_representation(valid_yaml):
"""Test the string representation of JobApplicationProfile."""
profile = JobApplicationProfile(valid_yaml)
profile_str = str(profile)
assert "Self Identification:" in profile_str
assert "Legal Authorization:" in profile_str
assert "Work Preferences:" in profile_str
assert "Availability:" in profile_str
assert "Salary Expectations:" in profile_str
assert "Male" in profile_str
assert "80000-120000" in profile_str

View file

@ -0,0 +1,158 @@
import pytest
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from src.linkedIn_authenticator import LinkedInAuthenticator
from selenium.common.exceptions import NoSuchElementException, TimeoutException
@pytest.fixture
def mock_driver(mocker):
"""Fixture to mock the Selenium WebDriver."""
return mocker.Mock()
@pytest.fixture
def authenticator(mock_driver):
"""Fixture to initialize LinkedInAuthenticator with a mocked driver."""
return LinkedInAuthenticator(mock_driver)
def test_set_secrets(authenticator):
"""Test setting secrets (email, password)."""
authenticator.set_secrets("test@example.com", "password123")
assert authenticator.email == "test@example.com"
assert authenticator.password == "password123"
def test_start_logged_in(mocker, authenticator):
"""Test starting LinkedIn when already logged in."""
mocker.patch.object(authenticator, 'is_logged_in', return_value=True)
mocker.patch.object(authenticator.driver, 'get')
mocker.patch("time.sleep") # Avoid waiting during the test
authenticator.start()
authenticator.driver.get.assert_called_with('https://www.linkedin.com/feed')
authenticator.is_logged_in.assert_called_once()
assert authenticator.driver.get.call_count == 1
def test_start_not_logged_in(mocker, authenticator):
"""Test starting LinkedIn when not logged in."""
mocker.patch.object(authenticator, 'is_logged_in', return_value=False)
mocker.patch.object(authenticator, 'handle_login')
mocker.patch.object(authenticator.driver, 'get')
mocker.patch("time.sleep")
authenticator.start()
authenticator.driver.get.assert_called_with('https://www.linkedin.com/feed')
authenticator.handle_login.assert_called_once()
def test_handle_login(mocker, authenticator):
"""Test handling the LinkedIn login process."""
mocker.patch.object(authenticator.driver, 'get')
mocker.patch.object(authenticator, 'enter_credentials')
mocker.patch.object(authenticator, 'submit_login_form')
mocker.patch.object(authenticator, 'handle_security_check')
# Mock current_url as a regular return value, not PropertyMock
mocker.patch.object(authenticator.driver, 'current_url', return_value='https://www.linkedin.com/login')
authenticator.handle_login()
authenticator.driver.get.assert_called_with('https://www.linkedin.com/login')
authenticator.enter_credentials.assert_called_once()
authenticator.submit_login_form.assert_called_once()
authenticator.handle_security_check.assert_called_once()
def test_enter_credentials_success(mocker, authenticator):
"""Test entering credentials."""
email_mock = mocker.Mock()
password_mock = mocker.Mock()
mocker.patch.object(WebDriverWait, 'until', return_value=email_mock)
mocker.patch.object(authenticator.driver, 'find_element', return_value=password_mock)
authenticator.set_secrets("test@example.com", "password123")
authenticator.enter_credentials()
email_mock.send_keys.assert_called_once_with("test@example.com")
password_mock.send_keys.assert_called_once_with("password123")
def test_enter_credentials_timeout(mocker, authenticator):
"""Test entering credentials with a TimeoutException."""
mocker.patch.object(WebDriverWait, 'until', side_effect=TimeoutException)
authenticator.set_secrets("test@example.com", "password123")
authenticator.enter_credentials()
authenticator.driver.find_element.assert_not_called() # Password input should not be accessed if email fails
def test_submit_login_form_success(mocker, authenticator):
"""Test submitting the login form."""
login_button_mock = mocker.Mock()
mocker.patch.object(authenticator.driver, 'find_element', return_value=login_button_mock)
authenticator.submit_login_form()
login_button_mock.click.assert_called_once()
def test_submit_login_form_no_button(mocker, authenticator):
"""Test submitting the login form when the login button is not found."""
mocker.patch.object(authenticator.driver, 'find_element', side_effect=NoSuchElementException)
authenticator.submit_login_form()
authenticator.driver.find_element.assert_called_once_with(By.XPATH, '//button[@type="submit"]')
def test_is_logged_in_true(mocker, authenticator):
"""Test if the user is logged in."""
buttons_mock = mocker.Mock()
buttons_mock.text = "Start a post"
mocker.patch.object(WebDriverWait, 'until')
mocker.patch.object(authenticator.driver, 'find_elements', return_value=[buttons_mock])
assert authenticator.is_logged_in() is True
def test_is_logged_in_false(mocker, authenticator):
"""Test if the user is not logged in."""
mocker.patch.object(WebDriverWait, 'until')
mocker.patch.object(authenticator.driver, 'find_elements', return_value=[])
assert authenticator.is_logged_in() is False
def test_handle_security_check_success(mocker, authenticator):
"""Test handling security check successfully."""
mocker.patch.object(WebDriverWait, 'until', side_effect=[
mocker.Mock(), # Security checkpoint detection
mocker.Mock() # Security check completion
])
authenticator.handle_security_check()
# Verify WebDriverWait is called with EC.url_contains for both the challenge and feed
WebDriverWait(authenticator.driver, 10).until.assert_any_call(mocker.ANY)
WebDriverWait(authenticator.driver, 300).until.assert_any_call(mocker.ANY)
def test_handle_security_check_timeout(mocker, authenticator):
"""Test handling security check timeout."""
mocker.patch.object(WebDriverWait, 'until', side_effect=TimeoutException)
authenticator.handle_security_check()
# Verify WebDriverWait is called with EC.url_contains for the challenge
WebDriverWait(authenticator.driver, 10).until.assert_any_call(mocker.ANY)

View file

@ -0,0 +1,14 @@
import pytest
# from src.linkedIn_job_manager import JobManager
@pytest.fixture
def job_manager():
"""Fixture for JobManager."""
return None # Replace with valid instance or mock later
def test_bot_functionality(job_manager):
"""Test LinkedIn bot facade."""
# Example: test job manager interacts with the bot facade correctly
job = {"title": "Software Engineer"}
# job_manager.some_method_to_apply(job)
assert job is not None # Placeholder for actual test

View file

@ -0,0 +1,97 @@
import pytest
from unittest import mock
from src.linkedIn_easy_applier import LinkedInEasyApplier
@pytest.fixture
def mock_driver():
"""Fixture to mock Selenium WebDriver."""
return mock.Mock()
@pytest.fixture
def mock_gpt_answerer():
"""Fixture to mock GPT Answerer."""
return mock.Mock()
@pytest.fixture
def mock_resume_generator_manager():
"""Fixture to mock Resume Generator Manager."""
return mock.Mock()
@pytest.fixture
def easy_applier(mock_driver, mock_gpt_answerer, mock_resume_generator_manager):
"""Fixture to initialize LinkedInEasyApplier with mocks."""
return LinkedInEasyApplier(
driver=mock_driver,
resume_dir="/path/to/resume",
set_old_answers=[('Question 1', 'Answer 1', 'Type 1')],
gpt_answerer=mock_gpt_answerer,
resume_generator_manager=mock_resume_generator_manager
)
def test_initialization(mocker, easy_applier):
"""Test that LinkedInEasyApplier is initialized correctly."""
# Mock os.path.exists to return True
mocker.patch('os.path.exists', return_value=True)
easy_applier = LinkedInEasyApplier(
driver=mocker.Mock(),
resume_dir="/path/to/resume",
set_old_answers=[('Question 1', 'Answer 1', 'Type 1')],
gpt_answerer=mocker.Mock(),
resume_generator_manager=mocker.Mock()
)
assert easy_applier.resume_path == "/path/to/resume"
assert len(easy_applier.set_old_answers) == 1
assert easy_applier.gpt_answerer is not None
assert easy_applier.resume_generator_manager is not None
def test_apply_to_job_success(mocker, easy_applier):
"""Test successfully applying to a job."""
mock_job = mock.Mock()
# Mock job_apply so we don't actually try to apply
mocker.patch.object(easy_applier, 'job_apply')
easy_applier.apply_to_job(mock_job)
easy_applier.job_apply.assert_called_once_with(mock_job)
def test_apply_to_job_failure(mocker, easy_applier):
"""Test failure while applying to a job."""
mock_job = mock.Mock()
mocker.patch.object(easy_applier, 'job_apply',
side_effect=Exception("Test error"))
with pytest.raises(Exception, match="Test error"):
easy_applier.apply_to_job(mock_job)
easy_applier.job_apply.assert_called_once_with(mock_job)
def test_check_for_premium_redirect_no_redirect(mocker, easy_applier):
"""Test that check_for_premium_redirect works when there's no redirect."""
mock_job = mock.Mock()
easy_applier.driver.current_url = "https://www.linkedin.com/jobs/view/1234"
easy_applier.check_for_premium_redirect(mock_job)
easy_applier.driver.get.assert_not_called()
def test_check_for_premium_redirect_with_redirect(mocker, easy_applier):
"""Test that check_for_premium_redirect handles LinkedIn Premium redirects."""
mock_job = mock.Mock()
easy_applier.driver.current_url = "https://www.linkedin.com/premium"
mock_job.link = "https://www.linkedin.com/jobs/view/1234"
with pytest.raises(Exception, match="Redirected to LinkedIn Premium page and failed to return"):
easy_applier.check_for_premium_redirect(mock_job)
# Verify that it attempted to return to the job page 3 times
assert easy_applier.driver.get.call_count == 3

View file

@ -0,0 +1,168 @@
from src.job import Job
from unittest import mock
from pathlib import Path
import os
import pytest
from src.linkedIn_job_manager import LinkedInJobManager
from selenium.common.exceptions import NoSuchElementException
@pytest.fixture
def job_manager(mocker):
"""Fixture to create a LinkedInJobManager instance with mocked driver."""
mock_driver = mocker.Mock()
return LinkedInJobManager(mock_driver)
def test_initialization(job_manager):
"""Test LinkedInJobManager initialization."""
assert job_manager.driver is not None
assert job_manager.set_old_answers == set()
assert job_manager.easy_applier_component is None
def test_set_parameters(mocker, job_manager):
"""Test setting parameters for the LinkedInJobManager."""
# Mocking os.path.exists to return True for the resume path
mocker.patch('pathlib.Path.exists', return_value=True)
params = {
'company_blacklist': ['Company A', 'Company B'],
'title_blacklist': ['Intern', 'Junior'],
'positions': ['Software Engineer', 'Data Scientist'],
'locations': ['New York', 'San Francisco'],
'apply_once_at_company': True,
'uploads': {'resume': '/path/to/resume'}, # Resume path provided here
'outputFileDirectory': '/path/to/output',
'job_applicants_threshold': {
'min_applicants': 5,
'max_applicants': 50
},
'remote': False,
'distance': 50,
'date': {'all time': True}
}
job_manager.set_parameters(params)
# Normalize paths to handle platform differences (e.g., Windows vs Unix-like systems)
assert str(job_manager.resume_path) == os.path.normpath('/path/to/resume')
assert str(job_manager.output_file_directory) == os.path.normpath(
'/path/to/output')
def next_job_page(self, position, location, job_page):
logger.debug("Navigating to next job page: %s in %s, page %d",
position, location, job_page)
self.driver.get(
f"https://www.linkedin.com/jobs/search/{self.base_search_url}&keywords={position}&location={location}&start={job_page * 25}")
def test_get_jobs_from_page_no_jobs(mocker, job_manager):
"""Test get_jobs_from_page when no jobs are found."""
mocker.patch.object(job_manager.driver, 'find_element',
side_effect=NoSuchElementException)
jobs = job_manager.get_jobs_from_page()
assert jobs == []
def test_get_jobs_from_page_with_jobs(mocker, job_manager):
"""Test get_jobs_from_page when job elements are found."""
# Mock the no_jobs_element to behave correctly
mock_no_jobs_element = mocker.Mock()
mock_no_jobs_element.text = "No matching jobs found"
# Mocking the find_element to return the mock no_jobs_element
mocker.patch.object(job_manager.driver, 'find_element',
return_value=mock_no_jobs_element)
# Mock the page_source
mocker.patch.object(job_manager.driver, 'page_source',
return_value="some page content")
# Ensure jobs are returned as empty list due to "No matching jobs found"
jobs = job_manager.get_jobs_from_page()
assert jobs == [] # No jobs expected due to "No matching jobs found"
def test_apply_jobs_with_no_jobs(mocker, job_manager):
"""Test apply_jobs when no jobs are found."""
# Mocking find_element to return a mock element that simulates no jobs
mock_element = mocker.Mock()
mock_element.text = "No matching jobs found"
# Mock the driver to simulate the page source
mocker.patch.object(job_manager.driver, 'page_source', return_value="")
# Mock the driver to return the mock element when find_element is called
mocker.patch.object(job_manager.driver, 'find_element',
return_value=mock_element)
# Call apply_jobs and ensure no exceptions are raised
job_manager.apply_jobs()
# Ensure it attempted to find the job results list
assert job_manager.driver.find_element.call_count == 1
def test_apply_jobs_with_jobs(mocker, job_manager):
"""Test apply_jobs when jobs are present."""
# Mock no_jobs_element to simulate the absence of "No matching jobs found" banner
no_jobs_element = mocker.Mock()
no_jobs_element.text = "" # Empty text means "No matching jobs found" is not present
mocker.patch.object(job_manager.driver, 'find_element',
return_value=no_jobs_element)
# Mock the page_source to simulate what the page looks like when jobs are present
mocker.patch.object(job_manager.driver, 'page_source',
return_value="some job content")
# Mock the outer find_elements (scaffold-layout__list-container)
container_mock = mocker.Mock()
# Mock the inner find_elements to return job list items
job_element_mock = mocker.Mock()
# Simulating two job items
job_elements_list = [job_element_mock, job_element_mock]
# Return the container mock, which itself returns the job elements list
container_mock.find_elements.return_value = job_elements_list
mocker.patch.object(job_manager.driver, 'find_elements',
return_value=[container_mock])
# Mock the extract_job_information_from_tile method to return sample job info
mocker.patch.object(job_manager, 'extract_job_information_from_tile', return_value=(
"Title", "Company", "Location", "Apply", "Link"))
# Mock other methods like is_blacklisted, is_already_applied_to_job, and is_already_applied_to_company
mocker.patch.object(job_manager, 'is_blacklisted', return_value=False)
mocker.patch.object(
job_manager, 'is_already_applied_to_job', return_value=False)
mocker.patch.object(
job_manager, 'is_already_applied_to_company', return_value=False)
# Mock the LinkedInEasyApplier component
job_manager.easy_applier_component = mocker.Mock()
# Mock the output_file_directory as a valid Path object
job_manager.output_file_directory = Path("/mocked/path/to/output")
# Mock Path.exists() to always return True (so no actual file system interaction is needed)
mocker.patch.object(Path, 'exists', return_value=True)
# Mock the open function to prevent actual file writing
mock_open = mocker.mock_open()
mocker.patch('builtins.open', mock_open)
# Run the apply_jobs method
job_manager.apply_jobs()
# Assertions
assert job_manager.driver.find_elements.call_count == 1
# Called for each job element
assert job_manager.extract_job_information_from_tile.call_count == 2
# Called for each job element
assert job_manager.easy_applier_component.job_apply.call_count == 2
mock_open.assert_called() # Ensure that the open function was called

96
tests/test_utils.py Normal file
View file

@ -0,0 +1,96 @@
# tests/test_utils.py
import pytest
import os
import time
from unittest import mock
from selenium.webdriver.remote.webelement import WebElement
from src.utils import ensure_chrome_profile, is_scrollable, scroll_slow, chrome_browser_options, printred, printyellow
# Mocking logging to avoid actual file writing
@pytest.fixture(autouse=True)
def mock_logger(mocker):
mocker.patch("src.utils.logger")
# Test ensure_chrome_profile function
def test_ensure_chrome_profile(mocker):
mocker.patch("os.path.exists", return_value=False) # Pretend directory doesn't exist
mocker.patch("os.makedirs") # Mock making directories
# Call the function
profile_path = ensure_chrome_profile()
# Verify that os.makedirs was called twice to create the directory
assert profile_path.endswith("linkedin_profile")
assert os.path.exists.called
assert os.makedirs.called
# Test is_scrollable function
def test_is_scrollable(mocker):
mock_element = mocker.Mock(spec=WebElement)
mock_element.get_attribute.side_effect = lambda attr: "1000" if attr == "scrollHeight" else "500"
# Call the function
scrollable = is_scrollable(mock_element)
# Check the expected outcome
assert scrollable is True
mock_element.get_attribute.assert_any_call("scrollHeight")
mock_element.get_attribute.assert_any_call("clientHeight")
# Test scroll_slow function
def test_scroll_slow(mocker):
mock_driver = mocker.Mock()
mock_element = mocker.Mock(spec=WebElement)
# Mock element's attributes for scrolling
mock_element.get_attribute.side_effect = lambda attr: "2000" if attr == "scrollHeight" else "0"
mock_element.is_displayed.return_value = True
mocker.patch("time.sleep") # Mock time.sleep to avoid waiting
# Call the function
scroll_slow(mock_driver, mock_element, start=0, end=1000, step=100, reverse=False)
# Ensure that scrolling happened multiple times
assert mock_driver.execute_script.called
mock_element.is_displayed.assert_called_once()
def test_scroll_slow_element_not_scrollable(mocker):
mock_driver = mocker.Mock()
mock_element = mocker.Mock(spec=WebElement)
# Mock the attributes so the element is not scrollable
mock_element.get_attribute.side_effect = lambda attr: "1000" if attr == "scrollHeight" else "1000"
mock_element.is_displayed.return_value = True
scroll_slow(mock_driver, mock_element, start=0, end=1000, step=100)
# Ensure it detected non-scrollable element
mock_driver.execute_script.assert_not_called()
# Test chrome_browser_options function
def test_chrome_browser_options(mocker):
mocker.patch("src.utils.ensure_chrome_profile")
mocker.patch("os.path.dirname", return_value="/mocked/path")
mocker.patch("os.path.basename", return_value="profile_directory")
mock_options = mocker.Mock()
mocker.patch("selenium.webdriver.ChromeOptions", return_value=mock_options)
# Call the function
options = chrome_browser_options()
# Ensure options were set
assert mock_options.add_argument.called
assert options == mock_options
# Test printred and printyellow functions
def test_printred(mocker):
mocker.patch("builtins.print")
printred("Test")
print.assert_called_once_with("\033[91mTest\033[0m")
def test_printyellow(mocker):
mocker.patch("builtins.print")
printyellow("Test")
print.assert_called_once_with("\033[93mTest\033[0m")