Алгоритмы и устройство программ
Введение
Понимание основ алгоритмов и устройства программ помогает тестировщику лучше понимать, как работают приложения, где могут возникать проблемы и как эффективнее планировать тестирование.
Зачем тестировщику знать алгоритмы:
- Понимать сложность операций и потенциальные проблемы производительности
- Лучше планировать тестовые данные и граничные случаи
- Эффективнее общаться с разработчиками
- Создавать более качественные автоматизированные тесты
- Понимать, где могут скрываться баги
Основы алгоритмов
Что такое алгоритм?
Алгоритм - это четкая последовательность действий для решения конкретной задачи.
Свойства хорошего алгоритма:
- Конечность - алгоритм должен завершаться за конечное время
- Определенность - каждый шаг четко определен
- Результативность - алгоритм должен давать результат
- Массовость - работает для класса задач, не только для одного случая
Пример простого алгоритма:
Алгоритм поиска максимального числа в массиве:
1. Взять первый элемент как максимальный
2. Для каждого следующего элемента:
a) Если элемент больше текущего максимума
b) Сделать его новым максимумом
3. Вернуть максимальный элемент
Псевдокод
Псевдокод - способ записи алгоритма на языке, близком к человеческому.
Функция FindMax(массив A):
максимум = A[0]
Для i от 1 до длина(A) - 1:
Если A[i] > максимум:
максимум = A[i]
Вернуть максимум
На JavaScript:
function findMax(array) {
let max = array[0];
for (let i = 1; i < array.length; i++) {
if (array[i] > max) {
max = array[i];
}
}
return max;
}
Сложность алгоритмов
Big O нотация
Big O показывает, как растет время выполнения алгоритма при увеличении размера входных данных.
Основные классы сложности:
O(1) - Константная сложность:
// Доступ к элементу массива по индексу
function getElement(array, index) {
return array[index]; // Всегда выполняется за одно действие
}
// Тестирование: время не зависит от размера массива
O(log n) - Логарифмическая сложность:
// Бинарный поиск в отсортированном массиве
function binarySearch(array, target) {
let left = 0;
let right = array.length - 1;
while (left <= right) {
let mid = Math.floor((left + right) / 2);
if (array[mid] === target) return mid;
if (array[mid] < target) left = mid + 1;
else right = mid - 1;
}
return -1;
}
// Тестирование: для массива в 1000 элементов ~10 операций
O(n) - Линейная сложность:
// Поиск элемента в неотсортированном массиве
function linearSearch(array, target) {
for (let i = 0; i < array.length; i++) {
if (array[i] === target) return i;
}
return -1;
}
// Тестирование: время пропорционально размеру массива
O(n²) - Квадратичная сложность:
// Поиск дубликатов в массиве (неэффективный способ)
function findDuplicates(array) {
let duplicates = [];
for (let i = 0; i < array.length; i++) {
for (let j = i + 1; j < array.length; j++) {
if (array[i] === array[j]) {
duplicates.push(array[i]);
}
}
}
return duplicates;
}
// Тестирование: для 1000 элементов ~1,000,000 операций
Практическое значение для тестирования
Планирование нагрузочных тестов:
O(1) алгоритм:
- 1,000 пользователей → 1,000 операций
- 10,000 пользователей → 10,000 операций
O(n²) алгоритм:
- 1,000 пользователей → 1,000,000 операций
- 10,000 пользователей → 100,000,000 операций (в 100 раз больше!)
Выбор тестовых данных:
Для O(log n) алгоритма:
- Тестировать с небольшими (10 элементов) и большими (10,000) наборами
- Производительность не должна сильно отличаться
Для O(n²) алгоритма:
- Быть осторожным с большими наборами данных
- Проверять таймауты на больших объемах
Структуры данных
Массивы (Arrays)
Характеристики:
- Доступ по индексу: O(1)
- Поиск элемента: O(n)
- Вставка в конец: O(1) - амортизированная
- Вставка в середину: O(n)
Тестирование массивов:
// Граничные случаи для тестирования
const testCases = [
[], // Пустой массив
[1], // Один элемент
[1, 2, 3], // Несколько элементов
new Array(1000).fill(0), // Большой массив
[null, undefined, ""], // Специальные значения
[-1, 0, 1], // Отрицательные, ноль, положительные
];
Объекты/Хэш-таблицы (Hash Maps)
Характеристики:
- Доступ по ключу: O(1) - в среднем
- Вставка: O(1) - в среднем
- Удаление: O(1) - в среднем
Пример использования:
// Кэш пользователей
const userCache = new Map();
function getUser(id) {
if (userCache.has(id)) {
return userCache.get(id); // O(1) - быстро
}
const user = fetchUserFromDatabase(id); // Медленно
userCache.set(id, user);
return user;
}
// Тестирование кэша
test('should cache user data', () => {
const userId = 123;
// Первый вызов - загружает из БД
const user1 = getUser(userId);
// Второй вызов - берет из кэша
const user2 = getUser(userId);
expect(user1).toBe(user2); // Тот же объект
});
Списки (Lists)
Связанный список:
class ListNode {
constructor(value) {
this.value = value;
this.next = null;
}
}
class LinkedList {
constructor() {
this.head = null;
this.size = 0;
}
append(value) {
const newNode = new ListNode(value);
if (!this.head) {
this.head = newNode;
} else {
let current = this.head;
while (current.next) {
current = current.next;
}
current.next = newNode;
}
this.size++;
}
find(value) {
let current = this.head;
while (current) {
if (current.value === value) return current;
current = current.next;
}
return null;
}
}
Тестирование связанного списка:
describe('LinkedList', () => {
test('should append elements', () => {
const list = new LinkedList();
list.append(1);
list.append(2);
expect(list.size).toBe(2);
expect(list.find(1)).toBeTruthy();
expect(list.find(2)).toBeTruthy();
expect(list.find(3)).toBeNull();
});
test('should handle empty list', () => {
const list = new LinkedList();
expect(list.find(1)).toBeNull();
expect(list.size).toBe(0);
});
});
Стеки и очереди
Стек (Stack) - LIFO (Last In, First Out):
class Stack {
constructor() {
this.items = [];
}
push(element) {
this.items.push(element);
}
pop() {
return this.items.pop();
}
peek() {
return this.items[this.items.length - 1];
}
isEmpty() {
return this.items.length === 0;
}
}
// Пример использования: история браузера
class BrowserHistory {
constructor() {
this.history = new Stack();
}
visit(page) {
this.history.push(page);
}
back() {
if (this.history.items.length > 1) {
this.history.pop(); // Убираем текущую страницу
return this.history.peek(); // Возвращаем предыдущую
}
return null;
}
}
Очередь (Queue) - FIFO (First In, First Out):
class Queue {
constructor() {
this.items = [];
}
enqueue(element) {
this.items.push(element);
}
dequeue() {
return this.items.shift();
}
front() {
return this.items[0];
}
isEmpty() {
return this.items.length === 0;
}
}
// Пример использования: очередь задач
class TaskQueue {
constructor() {
this.queue = new Queue();
this.processing = false;
}
addTask(task) {
this.queue.enqueue(task);
this.processNext();
}
async processNext() {
if (this.processing || this.queue.isEmpty()) return;
this.processing = true;
const task = this.queue.dequeue();
try {
await task.execute();
} catch (error) {
console.error('Task failed:', error);
}
this.processing = false;
this.processNext(); // Обработать следующую задачу
}
}
Алгоритмы сортировки
Пузырьковая сортировка (Bubble Sort)
Сложность: O(n²)
function bubbleSort(array) {
const n = array.length;
for (let i = 0; i < n - 1; i++) {
for (let j = 0; j < n - i - 1; j++) {
if (array[j] > array[j + 1]) {
// Обмен элементов
[array[j], array[j + 1]] = [array[j + 1], array[j]];
}
}
}
return array;
}
// Тестирование
const testArrays = [
[],
[1],
[3, 1, 2],
[5, 4, 3, 2, 1], // Худший случай
[1, 2, 3, 4, 5], // Лучший случай
[2, 2, 2, 2], // Одинаковые элементы
];
Быстрая сортировка (Quick Sort)
Сложность: O(n log n) - в среднем, O(n²) - в худшем случае
function quickSort(array, low = 0, high = array.length - 1) {
if (low < high) {
const pivotIndex = partition(array, low, high);
quickSort(array, low, pivotIndex - 1);
quickSort(array, pivotIndex + 1, high);
}
return array;
}
function partition(array, low, high) {
const pivot = array[high];
let i = low - 1;
for (let j = low; j < high; j++) {
if (array[j] <= pivot) {
i++;
[array[i], array[j]] = [array[j], array[i]];
}
}
[array[i + 1], array[high]] = [array[high], array[i + 1]];
return i + 1;
}
// Тестирование производительности
function measureSortingTime(sortFunction, array) {
const start = performance.now();
sortFunction([...array]); // Копия для чистого теста
const end = performance.now();
return end - start;
}
Алгоритмы поиска
Линейный поиск
Сложность: O(n)
function linearSearch(array, target) {
for (let i = 0; i < array.length; i++) {
if (array[i] === target) {
return i;
}
}
return -1;
}
// Тестирование различных случаев
describe('Linear Search', () => {
const array = [1, 3, 5, 7, 9];
test('should find existing element', () => {
expect(linearSearch(array, 5)).toBe(2);
});
test('should return -1 for non-existing element', () => {
expect(linearSearch(array, 6)).toBe(-1);
});
test('should find first element', () => {
expect(linearSearch(array, 1)).toBe(0);
});
test('should find last element', () => {
expect(linearSearch(array, 9)).toBe(4);
});
});
Бинарный поиск
Сложность: O(log n) Требование: Массив должен быть отсортирован
function binarySearch(array, target) {
let left = 0;
let right = array.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (array[mid] === target) {
return mid;
} else if (array[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
// Тестирование бинарного поиска
describe('Binary Search', () => {
test('should work with sorted array', () => {
const sorted = [1, 3, 5, 7, 9, 11, 13];
expect(binarySearch(sorted, 7)).toBe(3);
expect(binarySearch(sorted, 1)).toBe(0);
expect(binarySearch(sorted, 13)).toBe(6);
expect(binarySearch(sorted, 6)).toBe(-1);
});
test('should handle edge cases', () => {
expect(binarySearch([], 5)).toBe(-1);
expect(binarySearch([5], 5)).toBe(0);
expect(binarySearch([5], 3)).toBe(-1);
});
});
Рекурсия
Основы рекурсии
Рекурсия - это когда функция вызывает саму себя.
Компоненты рекурсивной функции:
- Базовый случай - условие остановки
- Рекурсивный случай - вызов функции с измененными параметрами
// Факториал числа
function factorial(n) {
// Базовый случай
if (n <= 1) {
return 1;
}
// Рекурсивный случай
return n * factorial(n - 1);
}
// Тестирование рекурсии
describe('Factorial', () => {
test('should calculate factorial correctly', () => {
expect(factorial(0)).toBe(1);
expect(factorial(1)).toBe(1);
expect(factorial(5)).toBe(120);
expect(factorial(10)).toBe(3628800);
});
test('should handle large numbers', () => {
// Проверка на переполнение стека
expect(() => factorial(10000)).toThrow();
});
});
Числа Фибоначчи
Неэффективная рекурсия:
function fibonacciSlow(n) {
if (n <= 1) return n;
return fibonacciSlow(n - 1) + fibonacciSlow(n - 2);
}
// Сложность: O(2^n) - очень медленно!
Эффективная рекурсия с мемоизацией:
function fibonacciFast(n, memo = {}) {
if (n in memo) return memo[n];
if (n <= 1) return n;
memo[n] = fibonacciFast(n - 1, memo) + fibonacciFast(n - 2, memo);
return memo[n];
}
// Сложность: O(n) - гораздо быстрее!
// Тестирование производительности
test('memoization should improve performance', () => {
const start1 = performance.now();
fibonacciSlow(35);
const time1 = performance.now() - start1;
const start2 = performance.now();
fibonacciFast(35);
const time2 = performance.now() - start2;
expect(time2).toBeLessThan(time1 / 100); // Мемоизация в 100+ раз быстрее
});
Устройство программ
Переменные и память
Типы данных и их размеры:
// Примитивные типы
let number = 42; // 8 байт (double precision)
let string = "Hello"; // Зависит от длины
let boolean = true; // 1 байт
let nullValue = null; // Специальное значение
let undefinedValue; // Специальное значение
// Ссылочные типы
let array = [1, 2, 3]; // Ссылка на объект в куче
let object = {name: "John"}; // Ссылка на объект в куче
// Тестирование работы с памятью
test('primitive vs reference types', () => {
let a = 5;
let b = a; // Копирование значения
a = 10;
expect(b).toBe(5); // b не изменилось
let obj1 = {value: 5};
let obj2 = obj1; // Копирование ссылки
obj1.value = 10;
expect(obj2.value).toBe(10); // obj2 изменилось!
});
Стек и куча
Стек (Stack):
- Хранит примитивные типы и ссылки
- Быстрый доступ
- Ограниченный размер
- Автоматическое управление памятью
Куча (Heap):
- Хранит объекты и массивы
- Медленнее доступ
- Больший размер
- Требует сборки мусора
function demonstrateMemory() {
// Хранится в стеке
let x = 10;
let y = 20;
// Ссылки хранятся в стеке, объекты в куче
let person = {name: "John", age: 30};
let numbers = [1, 2, 3, 4, 5];
return {person, numbers};
}
// Тестирование утечек памяти
test('should not leak memory', () => {
const initialMemory = process.memoryUsage().heapUsed;
for (let i = 0; i < 1000; i++) {
let largeArray = new Array(1000).fill(i);
// Массив должен быть очищен в конце итерации
}
// Принудительная сборка мусора (если доступна)
if (global.gc) global.gc();
const finalMemory = process.memoryUsage().heapUsed;
const memoryIncrease = finalMemory - initialMemory;
// Проверяем, что память не увеличилась значительно
expect(memoryIncrease).toBeLessThan(1024 * 1024); // Меньше 1MB
});
Циклы и их производительность
Цикл for:
function sumWithFor(numbers) {
let sum = 0;
for (let i = 0; i < numbers.length; i++) {
sum += numbers[i];
}
return sum;
}
Цикл for...of:
function sumWithForOf(numbers) {
let sum = 0;
for (const number of numbers) {
sum += number;
}
return sum;
}
Функциональный стиль:
function sumWithReduce(numbers) {
return numbers.reduce((sum, number) => sum + number, 0);
}
// Тестирование производительности циклов
function benchmarkLoops() {
const largeArray = new Array(1000000).fill(1);
console.time('for loop');
sumWithFor(largeArray);
console.timeEnd('for loop');
console.time('for...of loop');
sumWithForOf(largeArray);
console.timeEnd('for...of loop');
console.time('reduce');
sumWithReduce(largeArray);
console.timeEnd('reduce');
}
Условные операторы
Простые условия:
function getGrade(score) {
if (score >= 90) return 'A';
if (score >= 80) return 'B';
if (score >= 70) return 'C';
if (score >= 60) return 'D';
return 'F';
}
// Тестирование граничных случаев
describe('Grade calculation', () => {
test('boundary values', () => {
expect(getGrade(90)).toBe('A'); // Граница A
expect(getGrade(89)).toBe('B'); // Граница B
expect(getGrade(80)).toBe('B'); // Граница B
expect(getGrade(79)).toBe('C'); // Граница C
});
test('edge cases', () => {
expect(getGrade(0)).toBe('F');
expect(getGrade(100)).toBe('A');
expect(getGrade(-1)).toBe('F'); // Отрицательное число
expect(getGrade(101)).toBe('A'); // Больше максимума
});
});
Switch statement:
function getDayType(day) {
switch (day.toLowerCase()) {
case 'monday':
case 'tuesday':
case 'wednesday':
case 'thursday':
case 'friday':
return 'weekday';
case 'saturday':
case 'sunday':
return 'weekend';
default:
return 'invalid';
}
}
// Тестирование switch
test('day type classification', () => {
expect(getDayType('Monday')).toBe('weekday');
expect(getDayType('SATURDAY')).toBe('weekend');
expect(getDayType('invalid')).toBe('invalid');
expect(getDayType('')).toBe('invalid');
});
Обработка ошибок
Try-Catch блоки
function divideNumbers(a, b) {
try {
if (b === 0) {
throw new Error('Division by zero is not allowed');
}
return a / b;
} catch (error) {
console.error('Error in division:', error.message);
return null;
}
}
// Тестирование обработки ошибок
describe('Error handling', () => {
test('should handle division by zero', () => {
expect(divideNumbers(10, 0)).toBeNull();
});
test('should perform normal division', () => {
expect(divideNumbers(10, 2)).toBe(5);
});
test('should throw error for invalid input', () => {
expect(() => {
throw new Error('Test error');
}).toThrow('Test error');
});
});
Асинхронная обработка ошибок
async function fetchUserData(userId) {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const userData = await response.json();
return userData;
} catch (error) {
console.error('Failed to fetch user data:', error);
throw error; // Передаем ошибку дальше
}
}
// Тестирование асинхронных ошибок
test('should handle fetch errors', async () => {
// Мокаем fetch для возврата ошибки
global.fetch = jest.fn(() =>
Promise.resolve({
ok: false,
status: 404,
})
);
await expect(fetchUserData(123)).rejects.toThrow('HTTP error! status: 404');
});
Практические рекомендации для тестирования
Граничные случаи (Edge Cases)
Числовые границы:
function testNumericBoundaries(value) {
const testCases = [
0, // Ноль
1, // Минимальное положительное
-1, // Минимальное отрицательное
Number.MAX_VALUE, // Максимальное число
Number.MIN_VALUE, // Минимальное положительное число
Number.POSITIVE_INFINITY,
Number.NEGATIVE_INFINITY,
Number.NaN, // Not a Number
];
return testCases.map(testCase => ({
input: testCase,
result: yourFunction(testCase)
}));
}
Строковые границы:
function testStringBoundaries(str) {
const testCases = [
"", // Пустая строка
" ", // Пробел
"a", // Один символ
"A".repeat(1000), // Очень длинная строка
"тест", // Unicode символы
"🚀", // Emoji
"\n\t\r", // Специальные символы
null, // null
undefined, // undefined
];
return testCases.map(testCase => ({
input: testCase,
result: yourFunction(testCase)
}));
}
Тестирование производительности
function performanceTest(algorithm, testData) {
const iterations = 1000;
const start = performance.now();
for (let i = 0; i < iterations; i++) {
algorithm(testData);
}
const end = performance.now();
const averageTime = (end - start) / iterations;
return {
totalTime: end - start,
averageTime: averageTime,
iterations: iterations
};
}
// Сравнение алгоритмов
test('compare sorting algorithms performance', () => {
const testData = Array.from({length: 1000}, () => Math.random());
const bubbleSortResult = performanceTest(bubbleSort, testData);
const quickSortResult = performanceTest(quickSort, testData);
// Quick Sort должна быть быстрее Bubble Sort
expect(quickSortResult.averageTime).toBeLessThan(bubbleSortResult.averageTime);
});
Тестирование алгоритмической корректности
function testSortingCorrectness(sortFunction) {
const testCases = [
[],
[1],
[2, 1],
[3, 1, 2],
[5, 4, 3, 2, 1], // Обратно отсортированный
[1, 2, 3, 4, 5], // Уже отсортированный
[3, 3, 3, 3], // Одинаковые элементы
[-3, -1, 0, 2, 4], // Отрицательные числа
];
testCases.forEach(testCase => {
const original = [...testCase];
const sorted = sortFunction([...testCase]);
// Проверяем, что массив отсортирован
for (let i = 1; i < sorted.length; i++) {
expect(sorted[i]).toBeGreaterThanOrEqual(sorted[i-1]);
}
// Проверяем, что все элементы сохранились
expect(sorted.sort()).toEqual(original.sort());
});
}
Заключение
Понимание алгоритмов и устройства программ дает тестировщику:
- Лучшее понимание производительности - где могут быть узкие места
- Более эффективное планирование тестов - какие данные использовать
- Понимание граничных случаев - где вероятнее всего ошибки
- Эффективную коммуникацию с разработчиками - общий технический язык
- Навыки автоматизации - написание качественных тестов
Эти знания особенно важны при тестировании:
- Производительности приложений
- Обработки больших объемов данных
- Алгоритмически сложных функций
- Критически важных вычислений
Помните: вам не нужно быть экспертом в алгоритмах, но базовое понимание значительно повысит качество вашего тестирования.