Bài 13: Testing - unittest & pytest Basics
Mục Tiêu Bài Học
Sau khi hoàn thành bài này, bạn sẽ:
- ✅ Hiểu tầm quan trọng của testing
- ✅ Sử dụng unittest module
- ✅ Làm việc với pytest framework
- ✅ Viết test cases
- ✅ Sử dụng assertions
- ✅ Organize tests properly
Why Testing?
# Code without tests - riskydef calculate_discount(price, discount): return price * (1 - discount) # What if discount > 1?# What if price is negative?# What if discount is 0? # With tests - confidencedef test_calculate_discount(): assert calculate_discount(100, 0.1) == 90 assert calculate_discount(100, 0) == 100 assert calculate_discount(100, 1) == 0 # Benefits:# 1. Catch bugs early# 2. Prevent regressions# 3. Document behavior# 4. Enable refactoring# 5. Improve design
unittest Module
unittest là built-in testing framework trong Python.
Basic Test Case
import unittest # Code to testdef add(a, b): return a + b def subtract(a, b): return a - b # Test classclass TestMathFunctions(unittest.TestCase): def test_add(self): """Test add function.""" self.assertEqual(add(2, 3), 5) self.assertEqual(add(-1, 1), 0) self.assertEqual(add(0, 0), 0) def test_subtract(self): """Test subtract function.""" self.assertEqual(subtract(5, 3), 2) self.assertEqual(subtract(0, 5), -5) self.assertEqual(subtract(10, 10), 0) # Run testsif __name__ == '__main__': unittest.main() # Output:# ..# ----------------------------------------------------------------------# Ran 2 tests in 0.001s# OK
Assertion Methods
import unittest class TestAssertions(unittest.TestCase): def test_equality(self): """Test equality assertions.""" self.assertEqual(1 + 1, 2) self.assertNotEqual(1, 2) def test_boolean(self): """Test boolean assertions.""" self.assertTrue(True) self.assertFalse(False) def test_none(self): """Test None assertions.""" self.assertIsNone(None) self.assertIsNotNone("something") def test_membership(self): """Test membership assertions.""" self.assertIn('a', 'abc') self.assertNotIn('x', 'abc') def test_type(self): """Test type assertions.""" self.assertIsInstance(5, int) self.assertNotIsInstance("5", int) def test_greater_less(self): """Test comparison assertions.""" self.assertGreater(5, 3) self.assertLess(3, 5) self.assertGreaterEqual(5, 5) self.assertLessEqual(3, 5) def test_almost_equal(self): """Test floating point assertions.""" self.assertAlmostEqual(0.1 + 0.2, 0.3, places=7) def test_exceptions(self): """Test exception raising.""" with self.assertRaises(ValueError): int("not a number") with self.assertRaises(ZeroDivisionError): 1 / 0 if __name__ == '__main__': unittest.main()
setUp and tearDown
import unittest class TestWithSetup(unittest.TestCase): def setUp(self): """Run before each test.""" print("Setting up test") self.data = [1, 2, 3, 4, 5] def tearDown(self): """Run after each test.""" print("Tearing down test") self.data = None def test_sum(self): """Test sum of data.""" self.assertEqual(sum(self.data), 15) def test_length(self): """Test length of data.""" self.assertEqual(len(self.data), 5) @classmethod def setUpClass(cls): """Run once before all tests in class.""" print("Setup class") @classmethod def tearDownClass(cls): """Run once after all tests in class.""" print("Teardown class") if __name__ == '__main__': unittest.main()
pytest Framework
pytest là popular third-party testing framework, simpler và more powerful than unittest.
Installation
pip install pytest
Basic Test
# test_math.pydef add(a, b): return a + b def subtract(a, b): return a - b # Tests - simple functions starting with test_def test_add(): assert add(2, 3) == 5 assert add(-1, 1) == 0 assert add(0, 0) == 0 def test_subtract(): assert subtract(5, 3) == 2 assert subtract(0, 5) == -5 assert subtract(10, 10) == 0 # Run: pytest test_math.py
pytest Assertions
# test_assertions.pydef test_equality(): """Test equality.""" assert 1 + 1 == 2 assert "hello" == "hello" assert [1, 2, 3] == [1, 2, 3] def test_membership(): """Test membership.""" assert 'a' in 'abc' assert 'x' not in 'abc' assert 1 in [1, 2, 3] def test_type(): """Test types.""" assert isinstance(5, int) assert isinstance("hello", str) assert type(5) == int def test_comparison(): """Test comparisons.""" assert 5 > 3 assert 3 < 5 assert 5 >= 5 assert 5 <= 5 def test_boolean(): """Test boolean.""" assert True assert not False assert 1 # Truthy assert not 0 # Falsy def test_none(): """Test None.""" value = None assert value is None value = "something" assert value is not None # Run: pytest test_assertions.py -v
Testing Exceptions
# test_exceptions.pyimport pytest def divide(a, b): if b == 0: raise ValueError("Cannot divide by zero") return a / b def test_divide_success(): """Test successful division.""" assert divide(10, 2) == 5 assert divide(9, 3) == 3 def test_divide_by_zero(): """Test division by zero raises ValueError.""" with pytest.raises(ValueError) as exc_info: divide(10, 0) assert "Cannot divide by zero" in str(exc_info.value) def test_divide_by_zero_simple(): """Simpler syntax.""" with pytest.raises(ValueError): divide(10, 0) # Test multiple exceptionsdef test_int_conversion(): """Test invalid int conversion.""" with pytest.raises(ValueError): int("not a number")
Organizing Tests
Test File Structure
# Project structure# project/# src/# calculator.py# tests/# test_calculator.py# test_advanced.py # src/calculator.pyclass Calculator: def add(self, a, b): return a + b def subtract(self, a, b): return a - b def multiply(self, a, b): return a * b def divide(self, a, b): if b == 0: raise ValueError("Cannot divide by zero") return a / b # tests/test_calculator.pyimport pytestfrom src.calculator import Calculator class TestCalculator: def setup_method(self): """Setup for each test method.""" self.calc = Calculator() def test_add(self): assert self.calc.add(2, 3) == 5 assert self.calc.add(-1, 1) == 0 def test_subtract(self): assert self.calc.subtract(5, 3) == 2 assert self.calc.subtract(0, 5) == -5 def test_multiply(self): assert self.calc.multiply(3, 4) == 12 assert self.calc.multiply(0, 5) == 0 def test_divide(self): assert self.calc.divide(10, 2) == 5 assert self.calc.divide(9, 3) == 3 def test_divide_by_zero(self): with pytest.raises(ValueError): self.calc.divide(10, 0) # Run: pytest tests/
Parametrized Tests
# test_parametrized.pyimport pytest def add(a, b): return a + b @pytest.mark.parametrize("a,b,expected", [ (2, 3, 5), (0, 0, 0), (-1, 1, 0), (10, -5, 5),])def test_add_parametrized(a, b, expected): """Test add with multiple inputs.""" assert add(a, b) == expected # More complex parametrization@pytest.mark.parametrize("input,expected", [ ("hello", "HELLO"), ("World", "WORLD"), ("", ""), ("123", "123"),])def test_uppercase(input, expected): """Test string uppercase.""" assert input.upper() == expected # Multiple parameters@pytest.mark.parametrize("x", [1, 2, 3])@pytest.mark.parametrize("y", [4, 5, 6])def test_multiply(x, y): """Test multiplication combinations.""" assert x * y == y * x # Commutative # Run: pytest test_parametrized.py -v
Real-world Examples
1. Testing User Model
# models.pyclass User: def __init__(self, username, email, age): self.username = username self.email = email self.age = age def is_adult(self): return self.age >= 18 def validate(self): errors = [] if not self.username: errors.append("Username is required") if len(self.username) < 3: errors.append("Username must be at least 3 characters") if "@" not in self.email: errors.append("Invalid email") if self.age < 0: errors.append("Age cannot be negative") return errors # test_models.pyimport pytestfrom models import User class TestUser: def test_user_creation(self): """Test user creation.""" user = User("alice", "[email protected]", 25) assert user.username == "alice" assert user.email == "[email protected]" assert user.age == 25 def test_is_adult(self): """Test is_adult method.""" adult = User("bob", "[email protected]", 18) assert adult.is_adult() is True minor = User("charlie", "[email protected]", 17) assert minor.is_adult() is False def test_validate_valid_user(self): """Test validation of valid user.""" user = User("alice", "[email protected]", 25) errors = user.validate() assert len(errors) == 0 def test_validate_short_username(self): """Test validation with short username.""" user = User("ab", "[email protected]", 25) errors = user.validate() assert "Username must be at least 3 characters" in errors def test_validate_invalid_email(self): """Test validation with invalid email.""" user = User("alice", "invalid-email", 25) errors = user.validate() assert "Invalid email" in errors def test_validate_negative_age(self): """Test validation with negative age.""" user = User("alice", "[email protected]", -5) errors = user.validate() assert "Age cannot be negative" in errors
2. Testing API Client
# api_client.pyimport requests class APIClient: def __init__(self, base_url): self.base_url = base_url def get_user(self, user_id): response = requests.get(f"{self.base_url}/users/{user_id}") response.raise_for_status() return response.json() def create_user(self, data): response = requests.post(f"{self.base_url}/users", json=data) response.raise_for_status() return response.json() # test_api_client.pyimport pytestfrom api_client import APIClient class TestAPIClient: def test_get_user_success(self): """Test successful user fetch.""" client = APIClient("https://api.example.com") # Would need mocking for real test # user = client.get_user(1) # assert user['id'] == 1 def test_create_user_success(self): """Test successful user creation.""" client = APIClient("https://api.example.com") data = {"name": "Alice", "email": "[email protected]"} # Would need mocking for real test # user = client.create_user(data) # assert user['name'] == "Alice"
3. Testing String Utils
# string_utils.pydef slugify(text): """Convert text to URL-friendly slug.""" import re text = text.lower() text = re.sub(r'[^\w\s-]', '', text) text = re.sub(r'[\s_]+', '-', text) text = re.sub(r'^-+|-+$', '', text) return text def truncate(text, length, suffix="..."): """Truncate text to specified length.""" if len(text) <= length: return text return text[:length - len(suffix)] + suffix # test_string_utils.pyimport pytestfrom string_utils import slugify, truncate class TestSlugify: @pytest.mark.parametrize("input,expected", [ ("Hello World", "hello-world"), ("Python Programming", "python-programming"), ("[email protected]", "testexamplecom"), (" Spaces ", "spaces"), ("Multiple---Dashes", "multiple-dashes"), ]) def test_slugify(self, input, expected): assert slugify(input) == expected class TestTruncate: def test_truncate_short_text(self): """Test truncate with text shorter than limit.""" result = truncate("Hello", 10) assert result == "Hello" def test_truncate_long_text(self): """Test truncate with text longer than limit.""" result = truncate("Hello World", 8) assert result == "Hello..." def test_truncate_custom_suffix(self): """Test truncate with custom suffix.""" result = truncate("Hello World", 8, suffix=">>") assert result == "Hello >>" def test_truncate_exact_length(self): """Test truncate with exact length.""" result = truncate("Hello", 5) assert result == "Hello"
4. Testing Data Processor
# data_processor.pyclass DataProcessor: def __init__(self): self.data = [] def add_item(self, item): if not isinstance(item, dict): raise TypeError("Item must be a dictionary") if 'id' not in item: raise ValueError("Item must have 'id' field") self.data.append(item) def get_by_id(self, id): for item in self.data: if item['id'] == id: return item return None def filter_by(self, key, value): return [item for item in self.data if item.get(key) == value] def count(self): return len(self.data) # test_data_processor.pyimport pytestfrom data_processor import DataProcessor class TestDataProcessor: def setup_method(self): """Setup for each test.""" self.processor = DataProcessor() def test_add_item_success(self): """Test adding valid item.""" item = {'id': 1, 'name': 'Alice'} self.processor.add_item(item) assert self.processor.count() == 1 def test_add_item_invalid_type(self): """Test adding invalid type raises TypeError.""" with pytest.raises(TypeError): self.processor.add_item("not a dict") def test_add_item_missing_id(self): """Test adding item without id raises ValueError.""" with pytest.raises(ValueError): self.processor.add_item({'name': 'Alice'}) def test_get_by_id_found(self): """Test get_by_id when item exists.""" item = {'id': 1, 'name': 'Alice'} self.processor.add_item(item) result = self.processor.get_by_id(1) assert result == item def test_get_by_id_not_found(self): """Test get_by_id when item doesn't exist.""" result = self.processor.get_by_id(999) assert result is None def test_filter_by(self): """Test filtering items.""" self.processor.add_item({'id': 1, 'status': 'active'}) self.processor.add_item({'id': 2, 'status': 'inactive'}) self.processor.add_item({'id': 3, 'status': 'active'}) active = self.processor.filter_by('status', 'active') assert len(active) == 2 assert all(item['status'] == 'active' for item in active)
5. Testing File Utilities
# file_utils.pyimport osimport json def read_json(filepath): """Read JSON file.""" with open(filepath, 'r') as f: return json.load(f) def write_json(filepath, data): """Write JSON file.""" with open(filepath, 'w') as f: json.dump(data, f, indent=2) def ensure_dir(directory): """Create directory if it doesn't exist.""" if not os.path.exists(directory): os.makedirs(directory) # test_file_utils.pyimport pytestimport osimport jsonimport tempfileimport shutilfrom file_utils import read_json, write_json, ensure_dir class TestFileUtils: def setup_method(self): """Create temp directory for tests.""" self.test_dir = tempfile.mkdtemp() def teardown_method(self): """Clean up temp directory.""" shutil.rmtree(self.test_dir) def test_write_and_read_json(self): """Test writing and reading JSON.""" filepath = os.path.join(self.test_dir, 'test.json') data = {'name': 'Alice', 'age': 25} write_json(filepath, data) result = read_json(filepath) assert result == data def test_read_json_file_not_found(self): """Test reading non-existent file raises error.""" with pytest.raises(FileNotFoundError): read_json('nonexistent.json') def test_ensure_dir_creates_directory(self): """Test ensure_dir creates directory.""" new_dir = os.path.join(self.test_dir, 'new_directory') ensure_dir(new_dir) assert os.path.exists(new_dir) def test_ensure_dir_existing_directory(self): """Test ensure_dir with existing directory.""" ensure_dir(self.test_dir) # Should not raise error assert os.path.exists(self.test_dir)
Running Tests
# unittestpython -m unittest test_module.pypython -m unittest discover # Auto-discover tests # pytestpytest # Run all testspytest test_file.py # Run specific filepytest test_file.py::test_function # Run specific testpytest -v # Verbose outputpytest -v -s # Show print statementspytest -k "test_add" # Run tests matching patternpytest -x # Stop after first failurepytest --maxfail=2 # Stop after 2 failures
Best Practices
# 1. Test one thing per testdef test_add(): assert add(2, 3) == 5 # Good - one assertion # 2. Use descriptive test namesdef test_divide_by_zero_raises_value_error(): # Good - clear pass # 3. Arrange-Act-Assert patterndef test_user_creation(): # Arrange username = "alice" email = "[email protected]" # Act user = User(username, email) # Assert assert user.username == username # 4. Use fixtures for setup@pytest.fixturedef sample_user(): return User("alice", "[email protected]") def test_with_fixture(sample_user): assert sample_user.username == "alice" # 5. Test edge casesdef test_edge_cases(): assert add(0, 0) == 0 # Zero assert add(-1, 1) == 0 # Negative assert add(999999, 1) == 1000000 # Large numbers # 6. Keep tests fast# - Mock external dependencies# - Use in-memory databases# - Avoid sleep() calls # 7. Test behaviors, not implementation# Good: Test what function doesdef test_sorts_list(): assert sort([3, 1, 2]) == [1, 2, 3] # Bad: Test how it does itdef test_uses_quicksort(): # Too specific pass
Bài Tập Thực Hành
Bài 1: Test Calculator
Viết tests cho calculator:
- Basic operations
- Edge cases
- Error handling
- Parametrized tests
Bài 2: Test String Validator
Test validation functions:
- Email validation
- URL validation
- Phone validation
- Multiple test cases
Bài 3: Test Shopping Cart
Test shopping cart class:
- Add/remove items
- Calculate total
- Apply discounts
- Edge cases
Bài 4: Test File Handler
Test file operations:
- Read/write files
- Handle missing files
- Parse different formats
- Use temp files
Bài 5: Test API Wrapper
Test API wrapper (mocked):
- Successful requests
- Error handling
- Retry logic
- Response parsing
Tóm Tắt
✅ Testing: Catch bugs early, prevent regressions
✅ unittest: Built-in framework, TestCase class
✅ pytest: Simple, powerful, assert-based
✅ Assertions: assertEqual, assertTrue, assert
✅ Organization: One test per behavior, descriptive names
✅ Parametrization: Test multiple inputs efficiently
✅ Best practices: AAA pattern, test behaviors, keep fast
Bài Tiếp Theo
Bài 13.2: Testing - Part 2 - Fixtures, mocking, test coverage, và TDD! 🚀
Remember:
- Write tests for all critical code
- Test one thing per test
- Use descriptive names
- Test edge cases
- Keep tests fast and isolated! 🎯