Sitemap

Testing in Modern Software Development

Principles, Practices, and the Role of AI in Building Reliable and Maintainable Systems

Press enter or click to view image in full size

Introduction

This article outlines software testing as a core engineering practice, from fundamental principles to advanced strategies for large-scale systems.

1- Importance of Software Testing
2- Unit Testing
3- Principles of Writing Tests
4- Test Isolation and Mocking
5- AI-Assisted Test Writing
6- Large Domain Models & Test Data Strategy

1. Importance of Software Testing

In modern software development processes, writing tests has evolved beyond being merely a tool for detecting bugs; it has become a fundamental engineering practice that directly determines software quality. Especially when considering the development of complex systems, rapid release cycles, and continuous integration processes, testing plays a critical role in ensuring the sustainability of software.

1.1 About Software Quality

Software quality is the sum of criteria such as a system’s ability to correctly fulfill its expected functions, remain free of defects, be easy to maintain, and meet user expectations.

The core characteristics of high quality software include:
- Correctness
- Reliability
- Maintainability
- Performance
- Usability

Tests make these characteristics verifiable, turning software quality into something measurable and controllable. In particular, unit tests help ensure that different parts of the system function correctly and work as expected.

1.2 Bug Cost (Early vs Late Detection)

In the software development lifecycle, the point at which a bug is detected has a direct impact on its cost. A widely accepted principle states:

The later a bug is found, the more expensive it becomes to fix.

Early Stage (Development Phase)
-
Lower cost of fixing the bug
- The developer still remembers the context
- Limited impact scope

Late Stage (Production)
-
Affects end users
- Requires hotfixes
- May lead to reputational damage
- Introduces additional testing and deployment costs

By writing tests, bugs can be caught early during the development phase, significantly reducing these costs. Automated tests, in particular, run with every code change, helping detect issues as early as possible.

1.3 Regression Risk

Regression occurs when a feature that previously worked correctly breaks after changes are made to the system.

This risk increases especially after:
- Refactoring
- Adding new features
- Fixing bugs

One of the most important contributions of testing is minimizing this risk. Existing tests verify whether the system still preserves its previous behavior. This way, issues are caught before they reach production, significantly reducing both the cost and impact of potential bugs.

1.4 Contributions of Testing to the Software Development Process

1.4.1 Reducing Maintenance Cost

Tests create a safety net that verifies how the code behaves. As a result:
- New developers can understand the system more quickly
- The risk of making changes is reduced
- Debugging time for errors is shortened

1.4.2 Ensuring Refactoring Safety

During refactoring, one of the biggest concerns for developers is:
“Am I breaking something?”

Tests provide a clear answer to this question:
- Tests are executed after every refactoring
- If all tests pass, the system’s behavior is preserved

This enables developers to write cleaner code with greater confidence.

1.4.3 Acting as Documentation

Well-written tests serve as living documentation that demonstrates how the code behaves.

Example:

func test_appliesExtraFee_whenTotalWeightExceedsLimit()

It clearly expresses the following behavior: there is a defined weight limit, and if the total weight exceeds that limit, the system should apply an extra fee when performing the relevant check.
Hence:
- The expected behavior of the code is clearly visible
- The need for additional documentation is reduced

2. Unit Testing

(While there are various types of testing such as Integration, UI, Snapshot, End-to-End, and Smoke tests, this article focuses primarily on unit testing.)

Unit tests test the smallest testable parts of a software (usually functions or classes) in isolation.

Purpose
- To verify the correctness of business logic
- To validate small and independent components

Characteristics
-
Fast execution
- External dependencies are mocked
- Deterministic (always produces the same result)

Examples
-
A fee calculation function
- A data mapper
- A ViewModel state change

Advantages
-
Provides fast feedback
- Makes it easier to identify the source of errors
- Ensures safety during refactoring

What Unit Testing Is Not?

A unit test:
❌ Does not make network calls
❌ Does not use a real database
❌ Does not test UI rendering
❌ Does not test whether the programming language itself works correctly
❌ Should not depend on timing or async race conditions

// This example tests Swift's Array behavior
// Does not validate any business logic
// Provides no real value

let items = [1, 2, 3]

func test_arrayIsEmpty() {
let items: [Int] = []
XCTAssertTrue(items.isEmpty)
}

3. Principles of Writing Tests

Writing tests is not limited to knowing different test types. To create effective and maintainable tests, certain principles must be followed. These principles directly impact the readability, reliability, and long-term maintainability of tests.

3.1 FIRST Principles

A good test should have the following characteristics:

Fast

- Tests should run quickly
- Slow tests break the developer feedback loop
- Network and database should not be used

Independent

- Tests should not depend on each other
- The result of one test should not affect another
- Each test sets up its own environment

Repeatable

- A test should produce the same result every time it runs
❌ Date, randomness, network
✅ Mocks / fixed data

Self-validating

- The result of the test should be automatically verifiable
XCTAssertEqual(result, expected)

Timely

- Tests should be written as early as possible
- This approach also enables Test-Driven Development (TDD)

3.2 AAA Pattern (Arrange / Act / Assert)

One of the most commonly used structures for improving test readability

Arrange: Set up the necessary environment for the test
Act: Perform the action being tested
Assert: Verify the result

Example

func test_appliesExtraFee_whenTotalWeightExceedsLimit() {
// Arrange
let totalWeight = 25
let limit = 20

// Act
let result = calculator.calculateExtraFee(weight: totalWeight, limit: limit)

// Assert
XCTAssertTrue(result.hasExtraFee)
}

3.3 Test Naming

A good test name:
- Clearly describes what is being tested
- Includes both the scenario and the expected outcome
- Serves as documentation when read

There are multiple valid naming conventions for tests. The key is consistency and clarity.

// 1. action_whenCondition_expectedResult
func test_calculateFee_whenTotalWeightExceedsLimit_returnsExtraFee()

// 2. shouldExpectedResult_whenCondition
func test_shouldReturnExtraFee_whenTotalWeightExceedsLimit()

// 3. given_when_then
func test_givenWeightExceedsLimit_whenCalculatingFee_thenExtraFeeIsApplied()

// 4. behavior-focused
func test_extraFeeIsApplied_whenTotalWeightExceedsLimit()

Avoid using method names in test names. Method names can change during refactoring, but behavior should remain stable.

3.4 Single Responsibility

Each test should verify only one behavior.

// WRONG
func test_everything() {
XCTAssertTrue(result.isValid)
XCTAssertEqual(result.count, 3)
XCTAssertNotNil(result.error)
}

// CORRECT
func test_returnsValidResult_whenInputIsCorrect() { ... }
func test_returnsThreeItems_whenInputIsCorrect() { ... }

4. Test Isolation and Mocking

Test isolation is one of the fundamental principles that ensures unit tests are reliable, fast, and deterministic. A test should focus only on the unit under test and must not be affected by external dependencies. This isolation is achieved through mocking techniques.

Get Fatih Eren Özçelik’s stories in your inbox

Join Medium for free to get updates from this writer.

Without isolation:
-
Tests become slower
- Tests become fragile
- The source of errors becomes unclear

4.2 Dependency Problem

When a class directly depends on another concrete class:

class ProfileViewModel {
let service = ProfileService()
}

In this case:
- The real service is used during testing
- Test isolation is broken

4.3 Solution to dependency problem: Dependency Injection (DI)

Provide dependencies from the outside:

class ProfileViewModel {
private let service: ProfileServiceProtocol

init(service: ProfileServiceProtocol) {
self.service = service
}
}

This allows:
- Injecting a mock service instead of a real one
- Provides full control over the test environment

4.4 Mocking

Mocking is the practice of replacing real dependencies with controlled test doubles.

Types of Test Doubles

Mock

Used to verify behavior. It checks whether a method was called, how many times it was called, and with which parameters. The focus is on interactions rather than returned values.

Stub

Returns predefined, fixed data. It does not care how it is used; it simply provides the data needed for the test. It is mainly used to isolate the unit from external dependencies.

Spy

Records interactions during the test. After execution, you verify what happened, such as whether a method was called or which arguments were passed. Instead of defining expectations beforehand, you verify what happened after the test runs.

Fake

A simplified but working implementation of a real component. It contains actual logic, such as an in-memory database instead of a real one. It is useful when more realistic behavior is needed without full complexity.

Test Double Examples

Assume we have a NewsletterManager class that depends on an EmailService protocol.

protocol EmailService {
func sendWelcomeEmail(to email: String) -> Bool
func getSubscriberCount() -> Int
}

Mock

A mock verifies whether a function was called and with which parameters.
Answering questions like: “Was an email actually sent when the user signed up?”

class EmailServiceMock: EmailService {
var sendEmailCalled = false
var lastRecipient: String?

func sendWelcomeEmail(to email: String) -> Bool {
sendEmailCalled = true
lastRecipient = email
return true
}

func getSubscriberCount() -> Int {
return 0
}
}

// Test
func test_emailIsSent_whenUserSubscribes() {
let mock = EmailServiceMock()
let manager = NewsletterManager(service: mock)

manager.subscribe(email: "test@apple.com")

XCTAssertTrue(mock.sendEmailCalled)
XCTAssertEqual(mock.lastRecipient, "test@apple.com")
}

Stub

A stub provides the data required for the test to proceed. For example, getSubscriberCount might normally call an API, but in tests we want a fixed, predictable value.
Testing logic like: “If subscriber count is less than 100, show a warning.”

class EmailServiceStub: EmailService {
func getSubscriberCount() -> Int {
return 99
}

func sendWelcomeEmail(to email: String) -> Bool {
return true
}
}

func test_shouldShowWarning_whenSubscribersAreUnderLimit() {
// Arrange
let stub = EmailServiceStub() // This stub always returns 99
let sut = NewsletterManager(service: stub)

// Act
// Inside this call, sut calls service.getSubscriberCount() which returns 99
let isWarningVisible = sut.shouldShowWarning()

// Assert
XCTAssertTrue(isWarningVisible)
}

Spy

A spy is similar to a mock but focuses on recording interactions. It can track how many times a method was called and with which data.
Verifying scenarios like: “Was an email sent to 5 different users in a loop?”

class EmailServiceSpy: EmailService {
var callCount = 0
var emailsSent: [String] = []

func sendWelcomeEmail(to email: String) -> Bool {
callCount += 1
emailsSent.append(email)
return true
}

func getSubscriberCount() -> Int {
return 0
}
}

func test_sendBulkUpdate_shouldCallServiceForEveryEmail() {
// Arrange
let spy = EmailServiceSpy()
let sut = NewsletterManager(service: spy)
let recipients = ["alpha@test.com", "beta@test.com"]

// Act
sut.sendBulkUpdate(to: recipients)

// Assert
XCTAssertEqual(spy.callCount, 2)
XCTAssertEqual(spy.emailsSent, recipients)
}

Fake

A fake is a lightweight version of a real implementation. For example, instead of using a real database, it uses an in-memory structure.

class FakeEmailDatabase: EmailService {
private var subscribers = ["test@mail.com"]

func getSubscriberCount() -> Int {
return subscribers.count
}

func sendWelcomeEmail(to email: String) -> Bool {
subscribers.append(email)
return true
}
}

func test_registerUser_persistsUserInDatabase() {
// Arrange
let fakeDB = FakeEmailDatabase()
let sut = NewsletterManager(service: fakeDB)
let initialCount = fakeDB.getSubscriberCount()

// Act
sut.registerUser(email: "new_user@swift.org")

// Assert
let currentCount = fakeDB.getSubscriberCount()
XCTAssertEqual(currentCount, initialCount + 1, "The fake database did not persist the new user.")
}

Which One Should You Use?

Use Stub → when you only need controlled data
Use Mock → when you want to verify interactions
Use Spy → when you want to track calls and their details
Use Fake → when you need a working but simplified implementation

5. AI-Assisted Test Writing

AI powered tools are increasingly used in software development and provide significant advantages, especially in test writing. These tools help developers move faster by generating test scenarios, identifying edge cases and improving existing tests.

5.1 Role of AI in Test Writing

AI analyzes the code written by the developer and can
- Suggest test scenarios
- Generate test codes
- Identify missing edge cases
- Improve test coverage

5.2 Advantages

Speed: Significantly reduces the time required to write tests
Increased Coverage: Can identify scenarios developers might overlook
Learning Support: Acts as a guide for junior developers

5.3 Disadvantages and Risks

If sufficient context and clear rules are not provided, AI may fail to produce the expected results.

  1. Incorrect Test Generation
    AI may misinterpret the code and this can lead to incorrect assertions, missing scenarios and unnecessary tests.
  2. Lack of Business Context
    AI often lacks domain knowledge and interprets business rules superficially
  3. False Confidence
    The number of tests increases, but actual quality may not improve. Critical logic might remain untested
  4. Trivial Test Generation
    AI may generate low quality tests such as:
    - Getter / setter tests
    - Framework behavior tests
    - Scenarios that add no real value

Example

// Function
func isExtraFeeRequired(weight: Int, limit: Int) -> Bool {
return weight > limit
}

// AI Generated Test (with not sufficient context and rules)
func test_isExtraFeeRequired() {
XCTAssertTrue(calculator.isExtraFeeRequired(weight: 25, limit: 20))
}

// Problems with AI generated test:
// - Test name is not descriptive
// - No edge cases
// - No negative scenario

// --- Improved versions ---
func test_extraFeeIsRequired_whenWeightExceedsLimit() {
XCTAssertTrue(calculator.isExtraFeeRequired(weight: 25, limit: 20))
}

func test_extraFeeIsNotRequired_whenWeightIsBelowLimit() {
XCTAssertFalse(calculator.isExtraFeeRequired(weight: 15, limit: 20))
}

func test_extraFeeIsNotRequired_whenWeightEqualsLimit() {
XCTAssertFalse(calculator.isExtraFeeRequired(weight: 20, limit: 20))
}

5.6 Using AI for Test Writing

To achieve reliable results, AI must be guided with clear instructions such as:
- Following a proper structure (e.g., AAA pattern)
- Focusing on business logic rather than language or framework behavior
- Covering meaningful scenarios, including edge cases

6. Large Domain Models & Test Data Strategy

In large projects, as domain models grow, test data management becomes a critical bottleneck. The root cause is often the massive size of the domain models themselves, which makes mocking or manual stubbing nearly impossible to sustain.

To solve this, teams often introduce shared builders or shared mother stubs. However, this creates a new problem.

When these structures are shared:
1. A developer modifies them to fit their own test
2. Other tests still rely on the same structure
3. Those tests start failing due to unintended changes

6.1 Solution: Shared Builders & Mother Stubs (Not Recommended)

Many teams use a global Builder Pattern or Shared Mother Stubs to handle complexity. While they offer readability and reusability, they introduce significant risks in large-scale environments:
Global Fragility: A change in a builder’s default value or a shared mother’s property can silently break hundreds of unrelated tests.
Maintenance: Keeping global builders or shared stubs in sync with rapidly evolving models becomes a constant, centralized overhead.
Merge Conflicts: Constant merge conflicts occur as every developer edits the same global files to accommodate their specific test scenarios.

6.2 Solution: Specialized “Mother Stub” per Test Class (Recommended)

Instead of global builders, you can create a dedicated Mother Stub extension specifically for the test class (or module).

Core Principles

Isolation: The stub is local to the test context (file-scoped)
Valid Default Data: It provides a “Happy Path” object with all mandatory fields filled
Override by replacing: Each unit test fetches the mother stub and only replaces the specific fields necessary for that scenario

Example

You can use a fileprivate extension to create the stub, or you can just keep the test data directly inside the test class:

final class FeeCalculatorTests: XCTestCase {
private var calculator: FeeCalculator!

override func setUp() {
super.setUp()
calculator = FeeCalculator()
}

func test_extraFeeIsRequired_whenWeightExceedsLimit() {
// Arrange
var data = TestData.motherStub // approach 1
// or
var data = TransactionFeeDetail.motherStub // approach 2

data.currentWeight = 25
data.weightLimit = 20

// Act & Assert
XCTAssertTrue(calculator.isExtraFeeRequired(for: data))
}

// MARK: - Approach 1: Local Test Data Model inside the test class
private struct TestData {
static var motherStub: TransactionFeeDetail {
TransactionFeeDetail(
id: "TRX-001",
amount: 100.0,
currency: "USD",
weightLimit: 20,
currentWeight: 15,
customerType: .standard,
isPromotionActive: false
)
}
}
}


// MARK: - Approach 2: Local Stub Extension
// 'fileprivate' ensures this stub cannot be accessed from other test classes.
fileprivate extension TransactionFeeDetail {
static var motherStub: TransactionFeeDetail {
return TransactionFeeDetail(
id: "TRX-001",
amount: 100.0,
currency: "USD",
weightLimit: 20,
currentWeight: 15,
customerType: .standard,
isPromotionActive: false
)
}
}

Pros & Cons

Pros
- Isolation:
A change in one test file never breaks another
- No Merge Conflicts: No global “Mother” stub means no conflicts
- Locality: The test logic and its data live in the same place

Cons
- Duplication:
You might have similar stubs repeated across different test files
- Maintainability: Still hard to maintain if the models changes