gRPC (как обсуждается в API Testing Architecture: From Monoliths to Microservices) — это высокопроизводительный RPC-фреймворк с открытым исходным кодом, разработанный Google, который использует Protocol Buffers для сериализации и HTTP/2 для транспорта. Тестирование gRPC-сервисов требует специализированных подходов из-за их бинарного протокола, возможностей потоковой передачи и строгой типизации. Это подробное руководство охватывает все аспекты тестирования gRPC API.

Понимание архитектуры gRPC

Прежде чем углубляться в стратегии тестирования, важно понять основные концепции gRPC и то, чем они отличаются от традиционных REST API.

Сравнение gRPC и REST

ОсобенностьRESTgRPC
ПротоколHTTP/1.1HTTP/2
Формат данныхJSON (обычно)Protocol Buffers (бинарный)
Потоковая передачаОграниченная (SSE, WebSockets)Встроенная двунаправленная
Генерация кодаОпциональная (OpenAPI)Обязательная (protoc)
Поддержка браузераНативнаяТребуется gRPC-Web
ПроизводительностьХорошаяОтличная (бинарный, мультиплексирование)
Безопасность типовВалидация в runtimeВалидация во время компиляции

Бинарный протокол и генерация кода gRPC обеспечивают строгую безопасность типов, но требуют других инструментов тестирования по сравнению с REST (как обсуждается в GraphQL Testing: Complete Guide with Examples) API.

Protocol Buffers и тестирование схем

Protocol Buffers (protobuf) определяют контракт сервиса. Тестирование начинается с валидации ваших .proto файлов.

Валидация Proto-схемы

// user.proto
syntax = "proto3";

package user.v1;

service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
  rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
  rpc ListUsers(ListUsersRequest) returns (stream UserResponse);
  rpc StreamUsers(stream GetUserRequest) returns (stream UserResponse);
}

message GetUserRequest {
  string user_id = 1;
}

message GetUserResponse {
  string user_id = 1;
  string name = 2;
  string email = 3;
  int64 created_at = 4;
}

Линтинг и валидация схемы

Используйте buf или аналогичные инструменты для линтинга proto-файлов:

# Установить buf
brew install bufbuild/buf/buf

# Создать buf.yaml
version: v1
lint:
  use:
    - DEFAULT
breaking:
  use:
    - FILE
// Тест валидации схемы
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);

describe('Тесты Proto-схемы', () => {
  test('должен пройти линтинг proto', async () => {
    const { stdout, stderr } = await execAsync('buf lint');
    expect(stderr).toBe('');
  });

  test('не должен вводить ломающие изменения', async () => {
    try {
      await execAsync('buf breaking --against .git#branch=main');
    } catch (error) {
      fail('Обнаружены ломающие изменения: ' + error.stdout);
    }
  });
});

Тестирование унарного RPC

Унарные RPC — самая простая форма: один запрос, один ответ. Они аналогичны традиционным вызовам REST API.

Базовое тестирование унарного вызова

// Используя @grpc/grpc-js
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');

describe('Унарные тесты UserService', () => {
  let client;

  beforeAll(() => {
    const packageDefinition = protoLoader.loadSync('user.proto', {
      keepCase: true,
      longs: String,
      enums: String,
      defaults: true,
      oneofs: true
    });

    const userProto = grpc.loadPackageDefinition(packageDefinition).user.v1;

    client = new userProto.UserService(
      'localhost:50051',
      grpc.credentials.createInsecure()
    );
  });

  afterAll(() => {
    client.close();
  });

  test('должен получить пользователя по ID', (done) => {
    client.GetUser({ user_id: '123' }, (error, response) => {
      expect(error).toBeNull();
      expect(response.user_id).toBe('123');
      expect(response.name).toBeDefined();
      expect(response.email).toMatch(/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/);
      done();
    });
  });

  test('должен обработать ошибку пользователь не найден', (done) => {
    client.GetUser({ user_id: 'несуществующий' }, (error, response) => {
      expect(error).toBeDefined();
      expect(error.code).toBe(grpc.status.NOT_FOUND);
      expect(error.details).toContain('Пользователь не найден');
      done();
    });
  });

  test('должен валидировать обязательные поля', (done) => {
    client.GetUser({}, (error, response) => {
      expect(error).toBeDefined();
      expect(error.code).toBe(grpc.status.INVALID_ARGUMENT);
      done();
    });
  });
});

Тестирование gRPC с промисами

Оберните колбэки gRPC в промисы для более чистого синтаксиса async/await:

const { promisify } = require('util');

describe('UserService с промисами', () => {
  let client;
  let getUser;

  beforeAll(() => {
    // ... инициализировать client
    getUser = promisify(client.GetUser.bind(client));
  });

  test('должен создать пользователя', async () => {
    const createUser = promisify(client.CreateUser.bind(client));

    const response = await createUser({
      name: 'Иван Иванов',
      email: 'ivan@example.com',
      password: 'БезопасныйПароль123!'
    });

    expect(response.user_id).toBeDefined();
    expect(response.name).toBe('Иван Иванов');
    expect(response.email).toBe('ivan@example.com');
  });

  test('должен обрабатывать конкурентные запросы', async () => {
    const userIds = ['1', '2', '3', '4', '5'];

    const responses = await Promise.all(
      userIds.map(id => getUser({ user_id: id }))
    );

    expect(responses).toHaveLength(5);
    responses.forEach((response, index) => {
      expect(response.user_id).toBe(userIds[index]);
    });
  });
});

Тестирование серверной потоковой передачи

Серверная потоковая передача позволяет серверу отправлять несколько сообщений в ответ на один запрос клиента.

Тестирование серверных потоков

test('должен стримить список пользователей', (done) => {
  const users = [];

  const call = client.ListUsers({ limit: 100 });

  call.on('data', (user) => {
    users.push(user);
    expect(user.user_id).toBeDefined();
    expect(user.name).toBeDefined();
  });

  call.on('error', (error) => {
    done(error);
  });

  call.on('end', () => {
    expect(users.length).toBeGreaterThan(0);
    expect(users.length).toBeLessThanOrEqual(100);
    done();
  });
});

Тестирование производительности потоков

test('должен стримить пользователей эффективно', (done) => {
  const startTime = Date.now();
  let messageCount = 0;

  const call = client.ListUsers({ limit: 1000 });

  call.on('data', (user) => {
    messageCount++;

    // Проверить, что стриминг действительно происходит (не буферизуется)
    const elapsed = Date.now() - startTime;
    if (messageCount === 1) {
      // Первое сообщение должно прийти быстро
      expect(elapsed).toBeLessThan(100);
    }
  });

  call.on('end', () => {
    const totalTime = Date.now() - startTime;
    const throughput = messageCount / (totalTime / 1000);

    expect(messageCount).toBe(1000);
    // Должен достигать минимум 100 сообщений в секунду
    expect(throughput).toBeGreaterThan(100);
    done();
  });

  call.on('error', done);
}, 30000);

Обработка ошибок в потоках

test('должен корректно обрабатывать ошибки потока', (done) => {
  const call = client.ListUsers({ limit: -1 }); // Невалидный лимит

  call.on('data', () => {
    fail('Не должен получать данные с невалидными параметрами');
  });

  call.on('error', (error) => {
    expect(error.code).toBe(grpc.status.INVALID_ARGUMENT);
    done();
  });
});

Тестирование клиентской потоковой передачи

Клиентская потоковая передача позволяет клиенту отправлять несколько сообщений серверу, который отвечает одним сообщением.

Тестирование клиентских потоков

test('должен принимать пакетное создание пользователей', (done) => {
  const call = client.CreateUsersBatch((error, response) => {
    expect(error).toBeNull();
    expect(response.created_count).toBe(3);
    expect(response.user_ids).toHaveLength(3);
    done();
  });

  // Отправить несколько пользователей
  call.write({ name: 'Пользователь 1', email: 'user1@example.com' });
  call.write({ name: 'Пользователь 2', email: 'user2@example.com' });
  call.write({ name: 'Пользователь 3', email: 'user3@example.com' });

  call.end();
});

Тестирование обратного давления в клиентском потоке

test('должен обрабатывать обратное давление в клиентском стриминге', (done) => {
  const call = client.CreateUsersBatch((error, response) => {
    expect(error).toBeNull();
    expect(response.created_count).toBe(10000);
    done();
  });

  let writeCount = 0;
  const totalWrites = 10000;

  function writeNext() {
    let canWrite = true;

    while (canWrite && writeCount < totalWrites) {
      writeCount++;

      canWrite = call.write({
        name: `Пользователь ${writeCount}`,
        email: `user${writeCount}@example.com`
      });

      if (!canWrite) {
        // Дождаться события drain
        call.once('drain', writeNext);
      }
    }

    if (writeCount === totalWrites) {
      call.end();
    }
  }

  writeNext();
}, 30000);

Тестирование двунаправленной потоковой передачи

Двунаправленная потоковая передача позволяет и клиенту, и серверу независимо отправлять потоки сообщений.

Тестирование двунаправленных потоков

test('должен поддерживать чат с двунаправленным стримингом', (done) => {
  const call = client.StreamUsers();
  const receivedUsers = [];

  call.on('data', (user) => {
    receivedUsers.push(user);
  });

  call.on('end', () => {
    expect(receivedUsers.length).toBeGreaterThan(0);
    done();
  });

  call.on('error', done);

  // Отправить запросы как поток
  call.write({ user_id: '1' });
  call.write({ user_id: '2' });
  call.write({ user_id: '3' });

  setTimeout(() => {
    call.end();
  }, 1000);
}, 10000);

Тестирование коммуникации в реальном времени

test('должен обрабатывать двунаправленную коммуникацию в реальном времени', (done) => {
  const call = client.Chat();
  const responses = [];
  let messagesSent = 0;

  call.on('data', (message) => {
    responses.push(message);

    // Сервер должен отвечать быстро
    const responseTime = Date.now() - message.timestamp;
    expect(responseTime).toBeLessThan(100);

    if (responses.length === 5) {
      call.end();
    }
  });

  call.on('end', () => {
    expect(responses).toHaveLength(5);
    done();
  });

  call.on('error', done);

  // Отправить сообщения с временными метками
  const interval = setInterval(() => {
    if (messagesSent < 5) {
      call.write({
        user_id: '123',
        content: `Сообщение ${messagesSent + 1}`,
        timestamp: Date.now()
      });
      messagesSent++;
    } else {
      clearInterval(interval);
    }
  }, 100);
}, 10000);

Тестирование перехватчиков

Перехватчики позволяют перехватывать и модифицировать RPC-вызовы, аналогично middleware в HTTP-серверах.

Тестирование клиентского перехватчика

function createLoggingInterceptor() {
  return function (options, nextCall) {
    return new grpc.InterceptingCall(nextCall(options), {
      start: function (metadata, listener, next) {
        console.log('Начало вызова:', options.method_definition.path);
        next(metadata, {
          onReceiveMessage: function (message, next) {
            console.log('Получено:', message);
            next(message);
          }
        });
      }
    });
  };
}

test('должен применить перехватчик логирования', (done) => {
  const interceptedClient = new userProto.UserService(
    'localhost:50051',
    grpc.credentials.createInsecure(),
    { interceptors: [createLoggingInterceptor()] }
  );

  const consoleSpy = jest.spyOn(console, 'log');

  interceptedClient.GetUser({ user_id: '123' }, (error, response) => {
    expect(error).toBeNull();
    expect(consoleSpy).toHaveBeenCalledWith(
      'Начало вызова:',
      expect.stringContaining('GetUser')
    );
    consoleSpy.mockRestore();
    done();
  });
});

Тестирование перехватчика аутентификации

function createAuthInterceptor(token) {
  return function (options, nextCall) {
    return new grpc.InterceptingCall(nextCall(options), {
      start: function (metadata, listener, next) {
        metadata.add('authorization', `Bearer ${token}`);
        next(metadata, listener);
      }
    });
  };
}

test('должен добавить токен аутентификации через перехватчик', (done) => {
  const authClient = new userProto.UserService(
    'localhost:50051',
    grpc.credentials.createInsecure(),
    { interceptors: [createAuthInterceptor('test-token-123')] }
  );

  authClient.GetUser({ user_id: '123' }, (error, response) => {
    expect(error).toBeNull();
    // Проверить, что сервер получил токен (потребуется проверка на стороне сервера)
    expect(response).toBeDefined();
    done();
  });
});

Обработка ошибок и коды статуса

gRPC использует специфические коды статуса для обработки ошибок. Комплексное тестирование должно покрывать все сценарии ошибок.

Тестирование кодов статуса gRPC

const grpc = require('@grpc/grpc-js');

describe('Тесты обработки ошибок', () => {
  const errorScenarios = [
    {
      name: 'невалидный аргумент',
      input: { user_id: '' },
      expectedCode: grpc.status.INVALID_ARGUMENT
    },
    {
      name: 'не найден',
      input: { user_id: 'несуществующий' },
      expectedCode: grpc.status.NOT_FOUND
    },
    {
      name: 'доступ запрещен',
      input: { user_id: 'ограниченный' },
      expectedCode: grpc.status.PERMISSION_DENIED
    },
    {
      name: 'превышен дедлайн',
      input: { user_id: 'медленный' },
      expectedCode: grpc.status.DEADLINE_EXCEEDED,
      deadline: Date.now() + 100 // таймаут 100мс
    }
  ];

  errorScenarios.forEach(scenario => {
    test(`должен обработать ${scenario.name}`, (done) => {
      const options = scenario.deadline
        ? { deadline: scenario.deadline }
        : {};

      client.GetUser(scenario.input, options, (error, response) => {
        expect(error).toBeDefined();
        expect(error.code).toBe(scenario.expectedCode);
        done();
      });
    });
  });
});

Тестирование метаданных

test('должен отправлять и получать метаданные', (done) => {
  const metadata = new grpc.Metadata();
  metadata.add('request-id', 'test-123');
  metadata.add('client-version', '1.0.0');

  const call = client.GetUser(
    { user_id: '123' },
    metadata,
    (error, response) => {
      expect(error).toBeNull();
      expect(response).toBeDefined();
    }
  );

  call.on('metadata', (receivedMetadata) => {
    // Проверить метаданные ответа сервера
    const serverVersion = receivedMetadata.get('server-version');
    expect(serverVersion).toBeDefined();
    done();
  });
});

Нагрузочное тестирование и производительность

Эффективность gRPC делает его идеальным для высоконагруженных сценариев. Нагрузочное тестирование валидирует характеристики производительности.

Тестирование пропускной способности с ghz

Используйте ghz для нагрузочного тестирования gRPC:

# Установить ghz
go install github.com/bojand/ghz/cmd/ghz@latest

# Запустить нагрузочный тест
ghz --insecure \
  --proto user.proto \
  --call user.v1.UserService/GetUser \
  -d '{"user_id":"123"}' \
  -c 100 \
  -n 10000 \
  localhost:50051

Автоматизированное тестирование производительности

const { spawn } = require('child_process');

test('должен обработать 10000 запросов менее чем за 10 секунд', async () => {
  const ghz = spawn('ghz', [
    '--insecure',
    '--proto', 'user.proto',
    '--call', 'user.v1.UserService/GetUser',
    '-d', '{"user_id":"123"}',
    '-c', '100',
    '-n', '10000',
    '--format', 'json',
    'localhost:50051'
  ]);

  let output = '';

  ghz.stdout.on('data', (data) => {
    output += data.toString();
  });

  await new Promise((resolve) => ghz.on('close', resolve));

  const result = JSON.parse(output);

  expect(result.average).toBeLessThan(10 * 1000000000); // 10 секунд в наносекундах
  expect(result.rps).toBeGreaterThan(1000); // Минимум 1000 req/s
  expect(result.errorDistribution).toEqual({});
}, 30000);

Интеграционное тестирование

Тестируйте gRPC-сервисы с реальными зависимостями и базами данных.

Интеграция с TestContainers

const { GenericContainer } = require('testcontainers');

describe('Интеграционные тесты gRPC', () => {
  let container;
  let client;

  beforeAll(async () => {
    // Запустить PostgreSQL контейнер
    container = await new GenericContainer('postgres:14')
      .withEnv('POSTGRES_PASSWORD', 'test')
      .withExposedPorts(5432)
      .start();

    // Запустить gRPC сервер с подключением к БД
    // ... инициализировать client
  }, 60000);

  afterAll(async () => {
    await container.stop();
  });

  test('должен сохранять данные между запросами', async () => {
    const createUser = promisify(client.CreateUser.bind(client));
    const getUser = promisify(client.GetUser.bind(client));

    const created = await createUser({
      name: 'Интеграционный Тестовый Пользователь',
      email: 'integration@example.com'
    });

    const retrieved = await getUser({ user_id: created.user_id });

    expect(retrieved.name).toBe('Интеграционный Тестовый Пользователь');
    expect(retrieved.email).toBe('integration@example.com');
  });
});

Лучшие практики

Чеклист тестирования

  • ✅ Валидировать proto-схемы с помощью инструментов линтинга
  • ✅ Тестировать все типы RPC: унарный, серверный стриминг, клиентский стриминг, двунаправленный
  • ✅ Проверять обработку ошибок для всех кодов статуса gRPC
  • ✅ Тестировать перехватчики для аутентификации, логирования и метрик
  • ✅ Валидировать передачу метаданных
  • ✅ Тестировать поведение deadline и timeout
  • ✅ Выполнять нагрузочное тестирование для ожидаемой пропускной способности
  • ✅ Тестировать обработку обратного давления в потоках
  • ✅ Проверять бинарную сериализацию/десериализацию
  • ✅ Тестировать коммуникацию сервис-сервис

Советы по оптимизации производительности

// Использовать пулы соединений
const channelOptions = {
  'grpc.max_send_message_length': 100 * 1024 * 1024,
  'grpc.max_receive_message_length': 100 * 1024 * 1024,
  'grpc.keepalive_time_ms': 10000,
  'grpc.keepalive_timeout_ms': 5000
};

const client = new userProto.UserService(
  'localhost:50051',
  grpc.credentials.createInsecure(),
  channelOptions
);

Заключение

Тестирование gRPC API требует понимания protocol buffers, паттернов потоковой передачи и характеристик HTTP/2. Реализуя комплексные тесты для всех типов RPC, валидируя схемы, тестируя перехватчики и выполняя нагрузочное тестирование, вы обеспечиваете надежность и производительность ваших gRPC-сервисов.

Сосредоточьтесь на поведении потоковой передачи, обработке ошибок и характеристиках производительности, уникальных для gRPC. Используйте специализированные инструменты, такие как ghz для нагрузочного тестирования и buf для валидации схем. С этими практиками ваши gRPC-сервисы будут надежными и готовыми к продакшену.