Hello, web world! Enterprise edition

663914615b75a9d711a214b38c9b76d3.PNG

Отказ от ответственности:

Эта статья написана за 3 часа нейросетями, пока ждал когда коллеги отойдут от вчерашнего корпоратива. Код не проверялся и полон нейроглюков. Автор не фронт-эндер. Здесь интересна только композиция исходной задачи на мелкие подзадачи.
Извините за форматирование, никак не привыкну к здешнему формату.

INTRO
Я архитектор и бэк программист. Понадобилось реализовать модуль с развитым фронт-эндом. Оказалось что как единый компонент его реализовать слишком сложно. Попробую разбить на компоненты, особенно на фронт-энде.

Постановка моей задачи
Визард из 3 шагов.
— На первом этапе необходимо подать запрос пользователя с выбором и поиском по справочникам и вводом данных.
— Второй этап: несколько сущностей в виде вкладок. Кнопки добавления, удаления и копирования вкладки.
— Во вкладке форма с выбором и поиском по справочникам и вводом данных.
— При переходе на вкладку данные валидируются и сохраняются на сервере. Данные для справочников берутся с сервера.
— На третьем шаге отчет для проверки введенных данных.

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

ПОСЛЕДОВАТЕЛЬНОСТЬ РАБОТ:

Бэкенд API:

Проектирование API контрактов
Реализация API endpoints
Документация (OpenAPI/Swagger)
Тестирование API отдельно

Фронтенд с моками:

Разработка UI компонентов
Создание API клиента с моками
Тестирование компонентов изолированно
Тестирование взаимодействия компонентов

Фронтенд с реальными данными:

Реализация реальных API вызовов
Обработка загрузки/ошибок
Тестирование с реальным API

Интеграция:

E2E тестирование
Производительность
Мониторинг

Архитектурные и технологические решения

Архитектура приложения основана на разделении на фронтенд и бэкенд с четким разграничением ответственности и использованием современных технологий для обеспечения масштабируемости и поддерживаемости.

Технологический стек:

  • Бэкенд:

    • Язык программирования:  C# (на самом деле пофиг, бека тут мало)

    • Фреймворк:  ASP.NET Core Web API для создания RESTful API.

    • Доступ к данным:  Entity Framework Core или другой ORM для работы с базой данных.

    • База данных:  SQL Server, PostgreSQL или MongoDB в зависимости от требований.

    • Документация API:  Swagger/OpenAPI для автоматической генерации документации и контрактов.

    • Тестирование:  xUnit, NUnit или MSTest для модульного и интеграционного тестирования.

  • Фронтенд:

    • Язык программирования:  TypeScript для типизации и улучшенной надежности кода.

    • Библиотека UI:  React для построения пользовательского интерфейса.

    • Управление состоянием:  Redux или React Context API для управления состоянием приложения. (эти детали не описаны)

    • Роутинг:  React Router для управления навигацией между шагами визарда.

    • Стилизация:  CSS-in-JS (Emotion, Styled Components) или CSS Modules для модульной стилизации компонентов.

    • Тестирование:  Jest и React Testing Library для модульного и интеграционного тестирования компонентов.

Преимущества предлагаемой архитектуры:

  • Разделение ответственности (SoC):  Каждый модуль отвечает за свою область, что упрощает разработку и поддержку.

  • Тестируемость:  Легко проводить Unit-тестирование компонентов и API.

  • Масштабируемость:  Возможность расширения функциональности и добавления новых модулей.

  • Переиспользование кода:  Общие типы и константы используются как на Frontend, так и на Backend.

  • Параллельная разработка:  Frontend и Backend могут разрабатываться независимо.

Ключевые моменты:

  • Использование API-First подхода.

  • Генерация TypeScript типов из C# моделей (рекомендуется с помощью NSwag или OpenAPI Generator).

  • Использование моков на этапе разработки Frontend.

  • Покрытие кода тестами.

Структура солюшна.

Простой вариант
Solution/
├── Backend/
│ ├── UserForm.API/ # Основной проект API
│ │ ├── Controllers/
│ │ │ └── FormsController.cs # Эндпоинты API
│ │ ├── Models/ # Модели данных, общая бизнес логика
│ │ │ └── FormModel.cs
│ │ ├── Services/ # Бизнес-логика конкретных вариантов использования
│ │ │ └── FormService.cs
│ │ └── Program.cs
│ │
│ └── UserForm.Tests/ # Тесты бэкенда

├── Frontend/
│ ├── src/
│ │ ├── api/ # Работа с API
│ │ │ ├── formApi.ts # Клиент API
│ │ │ └── types.ts # Типы данных
│ │ │
│ │ ├── components/ # React компоненты
│ │ │ ├── FormWizard.tsx # Основной компонент визарда
│ │ │ └── FormStep.tsx # Компонент шага
│ │ │
│ │ └── App.tsx
│ │
│ └── tests/ # Тесты фронтенда

└── Shared/ # Общий код
└── Types/ # Общие типы
├── FormTypes.cs
└── FormTypes.ts

Сложный вариант

Solution/
├── .github/ # GitHub Actions, CI/CD
│ └── workflows/

├── Backend/
│ ├── UserForm.API/ # Web API проект
│ │ ├── Controllers/
│ │ │ ├── FormsController.cs
│ │ │ └── BaseController.cs
│ │ ├── Middleware/
│ │ │ ├── ErrorHandlingMiddleware.cs
│ │ │ └── LoggingMiddleware.cs
│ │ ├── Configuration/
│ │ │ └── SwaggerConfig.cs
│ │ ├── Program.cs
│ │ ├── Startup.cs
│ │ └── appsettings.json
│ │
│ ├── UserForm.Core/ # Бизнес-логика
│ │ ├── Models/
│ │ │ ├── Form.cs
│ │ │ └── Step.cs
│ │ ├── Services/
│ │ │ ├── Interfaces/
│ │ │ │ └── IFormService.cs
│ │ │ └── FormService.cs
│ │ ├── Validation/
│ │ │ ├── Validators/
│ │ │ │ └── FormValidator.cs
│ │ │ └── Rules/
│ │ └── Exceptions/
│ │ └── BusinessException.cs
│ │
│ ├── UserForm.Infrastructure/ # Доступ к данным
│ │ ├── Data/
│ │ │ ├── Repositories/
│ │ │ │ ├── IFormRepository.cs
│ │ │ │ └── FormRepository.cs
│ │ │ └── Configurations/
│ │ │ └── FormConfiguration.cs
│ │ ├── Persistence/
│ │ │ ├── FormDbContext.cs
│ │ │ └── Migrations/
│ │ └── Services/
│ │ └── External/ # Внешние сервисы
│ │
│ ├── UserForm.Shared/ # Общие DTO и контракты
│ │ ├── DTOs/
│ │ │ ├── FormDTO.cs
│ │ │ └── ValidationDTO.cs
│ │ ├── Constants/
│ │ │ └── ApiRoutes.cs
│ │ └── Extensions/
│ │
│ └── Tests/
│ ├── UserForm.UnitTests/
│ │ ├── Services/
│ │ └── Validators/
│ ├── UserForm.IntegrationTests/
│ │ └── API/
│ └── UserForm.E2ETests/

├── Frontend/
│ ├── public/
│ │ └── index.html
│ │
│ ├── src/
│ │ ├── api/ # API клиенты
│ │ │ ├── types.ts
│ │ │ ├── formApi.ts
│ │ │ └── generated/ # Автогенерированные типы
│ │ │
│ │ ├── components/ # React компоненты
│ │ │ ├── common/ # Общие компоненты
│ │ │ │ ├── Button/
│ │ │ │ ├── Input/
│ │ │ │ └── ErrorBoundary/
│ │ │ │
│ │ │ ├── Form/ # Компоненты формы
│ │ │ │ ├── FormWizard/
│ │ │ │ ├── FormStep/
│ │ │ │ └── FormNavigation/
│ │ │ │
│ │ │ └── Layout/ # Компоненты лейаута
│ │ │
│ │ ├── hooks/ # React хуки
│ │ │ ├── useForm.ts
│ │ │ └── useApi.ts
│ │ │
│ │ ├── store/ # Управление состоянием
│ │ │ ├── slices/
│ │ │ └── store.ts
│ │ │
│ │ ├── utils/ # Утилиты
│ │ │ ├── validation.ts
│ │ │ └── formatting.ts
│ │ │
│ │ ├── styles/ # Стили
│ │ │ ├── global.css
│ │ │ └── variables.css
│ │ │
│ │ ├── config/ # Конфигурация
│ │ │ └── api.ts
│ │ │
│ │ ├── App.tsx
│ │ └── index.tsx
│ │
│ ├── tests/ # Тесты
│ │ ├── unit/
│ │ │ └── components/
│ │ ├── integration/
│ │ └── e2e/
│ │
│ ├── .storybook/ # Storybook конфигурация
│ ├── package.json
│ ├── tsconfig.json
│ └── vite.config.ts

├── Shared/ # Общий код
│ ├── Types/ # Общие типы
│ │ ├── FormTypes.cs
│ │ └── FormTypes.ts
│ │
│ └── Constants/ # Общие константы
│ ├── ApiRoutes.cs
│ └── ApiRoutes.ts

├── Tools/ # Инструменты разработки
│ ├── CodeGen/ # Генераторы кода
│ └── Scripts/ # Скрипты сборки/деплоя

├── docs/ # Документация
│ ├── api/
│ ├── architecture/
│ └── deployment/

├── .editorconfig # Настройки редактора
├── .gitignore
├── docker-compose.yml # Docker конфигурация
├── README.md
└── Solution.sln

Заметим, что одни и те же типы (если использовать typescript) используются как на фронте так и на беке.

Разберем пошаговую разработку бэкенда на C#:

  1. Сначала определяем модели и контракты:

    Переиспользование типов фронтенда и бекэнда:

# api-spec.yaml
openapi: 3.0.0
info:
  title: User Form API
  version: 1.0.0
paths:
  /api/forms/{id}:
    get:
      summary: Get form data
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        200:
          description: Form data
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FormDTO'
    put:
      summary: Update form data
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/FormDTO'
      responses:
        200:
          description: Updated form data
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FormDTO'

components:
  schemas:
    FormDTO:
      type: object
      properties:
        id:
          type: string
        step:
          type: string
          enum: [personal, address, payment]
        data:
          type: object
          additionalProperties: true
        status:
          type: string
          enum: [draft, complete, error]
  1. Генерируем C# код:

# Используем NSwag
nswag openapi2csclient /input:api-spec.yaml /output:Backend/Generated/ApiClient.cs

# Или используем OpenAPI Generator
openapi-generator generate -i api-spec.yaml -g aspnetcore -o Backend/Generated
  1. Генерируем TypeScript код:

# Генерация TypeScript клиента
openapi-generator generate -i api-spec.yaml -g typescript-fetch -o Frontend/src/api/generated
  1. Реализуем API на бэкенде используя сгенерированные интерфейсы:

// Backend/Controllers/FormsController.cs
[ApiController]
[Route("api/[controller]")]
public class FormsController : ControllerBase, IFormsApi
{
    public async Task> GetForm(string id)
    {
        // Реализация
    }

    public async Task> UpdateForm(string id, FormDTO form)
    {
        // Реализация
    }
}
  1. Используем сгенерированный клиент на фронтенде:

// Frontend/src/components/FormWizard.tsx
import { FormsApi, FormDTO } from '../api/generated';

export const FormWizard: React.FC<{ id: string }> = ({ id }) => {
    const api = new FormsApi();
    const [form, setForm] = useState();

    useEffect(() => {
        api.getForm(id).then(setForm);
    }, [id]);

    // Остальной код
};

Преимущества этого подхода:

  1. API контракт становится источником правды

  2. Фронт и бэк могут разрабатываться параллельно

  3. Легко поддерживать совместимость

  4. Автоматическая документация

  5. Типобезопасность на обеих сторонах

Недостатки:
простой контракт проще написать вручную

…вернемся к разработке бекэнда…


2. Создаем интерфейс сервиса на бекэнде:

// Services/IUserFormService.cs
public interface IUserFormService
{
    Task GetFormAsync(string id);
    Task SaveFormAsync(string id, UserForm form);
    Task ValidateStepAsync(string id, FormStep step, Dictionary data);
}

// Services/UserFormService.cs
public class UserFormService : IUserFormService
{
	// конструктор с внедрением зависимостей
    private readonly IUserFormRepository _repository;
    private readonly IValidator _validator;

    public UserFormService(IUserFormRepository repository, IValidator validator)
    {
        _repository = repository;
        _validator = validator;
    }
	
	// реализации того что обещали в IUserFormService
    public async Task GetFormAsync(string id)
    {
        var form = await _repository.GetByIdAsync(id);
        if (form == null)
        {
            throw new NotFoundException($"Form {id} not found");
        }
        return form;
    }

    public async Task SaveFormAsync(string id, UserForm form)
    {
        form.LastUpdated = DateTime.UtcNow;
        
        var validationResult = await _validator.ValidateAsync(form);
        if (!validationResult.IsValid)
        {
            form.Status = FormStatus.Error;
            form.ValidationErrors = validationResult.Errors
                .Select(e => e.ErrorMessage)
                .ToList();
        }

        return await _repository.SaveAsync(id, form);
    }
}
  1. Реализуем репозиторий:

// Repository/IUserFormRepository.cs
public interface IUserFormRepository
{
    Task GetByIdAsync(string id);
    Task SaveAsync(string id, UserForm form);
}

// Repository/UserFormRepository.cs
public class UserFormRepository : IUserFormRepository
{
    private readonly IMongoCollection _forms;

    public UserFormRepository(IMongoDatabase database)
    {
        _forms = database.GetCollection("userForms");
    }

    public async Task GetByIdAsync(string id)
    {
        return await _forms.Find(f => f.Id == id).FirstOrDefaultAsync();
    }

    public async Task SaveAsync(string id, UserForm form)
    {
        await _forms.ReplaceOneAsync(
            f => f.Id == id,
            form,
            new ReplaceOptions { IsUpsert = true }
        );
        return form;
    }
}
  1. Добавляем валидацию:

// Validation/UserFormValidator.cs
public class UserFormValidator : AbstractValidator
{
    public UserFormValidator()
    {
        RuleFor(x => x.Id).NotEmpty();
        RuleFor(x => x.Step).IsInEnum();
        RuleFor(x => x.Status).IsInEnum();
        
        When(x => x.Step == FormStep.Personal, () => {
            RuleFor(x => x.Data)
                .Must(HaveRequiredPersonalFields)
                .WithMessage("Missing required personal information");
        });
    }

    private bool HaveRequiredPersonalFields(Dictionary data)
    {
        return data != null &&
               data.ContainsKey("firstName") &&
               data.ContainsKey("lastName");
    }
}
  1. Создаем контроллер:

// Controllers/UserFormController.cs
[ApiController]
[Route("api/forms")]
public class UserFormController : ControllerBase
{
    private readonly IUserFormService _formService;
    private readonly IMapper _mapper;

    public UserFormController(IUserFormService formService, IMapper mapper)
    {
        _formService = formService;
        _mapper = mapper;
    }

    [HttpGet("{id}")]
    public async Task> GetForm(string id)
    {
        try
        {
            var form = await _formService.GetFormAsync(id);
            return Ok(_mapper.Map(form));
        }
        catch (NotFoundException ex)
        {
            return NotFound(ex.Message);
        }
    }

    [HttpPut("{id}")]
    public async Task> SaveForm(string id, UserFormDto dto)
    {
        var form = _mapper.Map(dto);
        var savedForm = await _formService.SaveFormAsync(id, form);
        return Ok(_mapper.Map(savedForm));
    }
}
  1. Настраиваем AutoMapper:
    для автоматического преобразования между разными представлениями объектов, напр-р между внутренними моделями и DTO — в данном случае излишне, убрал

  2. Добавляем тесты:

// Tests/UserFormServiceTests.cs
public class UserFormServiceTests
{
    private readonly Mock _repositoryMock;
    private readonly Mock> _validatorMock;
    private readonly UserFormService _service;

    public UserFormServiceTests()
    {
        _repositoryMock = new Mock();
        _validatorMock = new Mock>();
        _service = new UserFormService(_repositoryMock.Object, _validatorMock.Object);
    }

    [Fact]
    public async Task GetForm_WhenExists_ReturnsForm()
    {
        // Arrange
        var form = new UserForm { Id = "test" };
        _repositoryMock.Setup(r => r.GetByIdAsync("test"))
            .ReturnsAsync(form);

        // Act
        var result = await _service.GetFormAsync("test");

        // Assert
        Assert.Equal(form, result);
    }

    [Fact]
    public async Task SaveForm_WithValidData_SavesAndReturnsForm()
    {
        // Arrange
        var form = new UserForm { Id = "test" };
        _validatorMock.Setup(v => v.ValidateAsync(It.IsAny(), default))
            .ReturnsAsync(new ValidationResult());
        _repositoryMock.Setup(r => r.SaveAsync("test", It.IsAny()))
            .ReturnsAsync(form);

        // Act
        var result = await _service.SaveFormAsync("test", form);

        // Assert
        Assert.Equal(form, result);
    }
}
  1. Настройка DI в Startup:

// Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.AddAutoMapper(typeof(UserFormProfile));
    
    services.AddScoped();
    services.AddScoped();
    services.AddScoped, UserFormValidator>();
    
    services.AddSwaggerGen(c =>
    {
        c.SwaggerDoc("v1", new OpenApiInfo { Title = "UserForm API", Version = "v1" });
    });
}

Шаги проектирования и реализации фронт:

// api/types.ts
// Определяем все типы и интерфейсы для API
export interface UserFormDTO {
  id: string;
  step: FormStep;
  data: Record;
  status: FormStatus;
  lastUpdated?: string;
  validationErrors?: string[];
}

export type FormStep = 'personal' | 'address' | 'payment';
export type FormStatus = 'draft' | 'complete' | 'error';

// Интерфейс для API клиента
export interface UserFormApi {
  getFormData(id: string): Promise;
  saveFormData(id: string, data: Partial): Promise;
  validateStep(id: string, step: FormStep, data: any): Promise;
}
// api/userFormApi.ts
// Реализация API клиента с поддержкой моков для разработки
import { UserFormApi, UserFormDTO } from './types';
import { mockData } from './mockData';

export class UserFormApiClient implements UserFormApi {
  private readonly baseUrl: string;
  private readonly useMocks: boolean;

  constructor(config = {
    baseUrl: process.env.REACT_APP_API_URL,
    useMocks: process.env.REACT_APP_USE_MOCKS === 'true'
  }) {
    this.baseUrl = config.baseUrl;
    this.useMocks = config.useMocks;
  }

  // Получение данных формы
  async getFormData(id: string): Promise {
    if (this.useMocks) {
      // В режиме разработки используем моки
      return mockData[id];
    }

    // В продакшене делаем реальный API запрос
    const response = await fetch(`${this.baseUrl}/forms/${id}`);
    if (!response.ok) {
      throw new Error(`API Error: ${response.statusText}`);
    }
    return response.json();
  }

  // Сохранение данных формы
  async saveFormData(id: string, data: Partial): Promise {
    if (this.useMocks) {
      // Имитируем задержку сети
      await new Promise(resolve => setTimeout(resolve, 500));
      return {
        ...mockData[id],
        ...data,
        lastUpdated: new Date().toISOString()
      };
    }

    const response = await fetch(`${this.baseUrl}/forms/${id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data)
    });

    if (!response.ok) {
      throw new Error(`Save Error: ${response.statusText}`);
    }
    return response.json();
  }
}
// hooks/useFormData.ts
// Хук для работы с данными формы, инкапсулирует логику загрузки и обработки ошибок
import { useState, useEffect } from 'react';
import { UserFormApiClient } from '../api/userFormApi';
import { UserFormDTO } from '../api/types';

export function useFormData(formId: string) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  // Загрузка данных при монтировании или изменении formId
  useEffect(() => {
    const api = new UserFormApiClient();
    let mounted = true;

    async function loadData() {
      try {
        const result = await api.getFormData(formId);
        if (mounted) {
          setData(result);
        }
      } catch (err) {
        if (mounted) {
          setError(err as Error);
        }
      } finally {
        if (mounted) {
          setLoading(false);
        }
      }
    }

    loadData();

    // Очистка при размонтировании
    return () => {
      mounted = false;
    };
  }, [formId]);

  // Функция для сохранения данных
  const saveData = async (newData: Partial) => {
    setLoading(true);
    try {
      const api = new UserFormApiClient();
      const updated = await api.saveFormData(formId, newData);
      setData(updated);
      return updated;
    } catch (err) {
      setError(err as Error);
      throw err;
    } finally {
      setLoading(false);
    }
  };

  return { data, loading, error, saveData };
}
// components/UserForm/UserForm.tsx
// Основной компонент формы, управляет состоянием и навигацией
import React, { useState } from 'react';
import { useFormData } from '../../hooks/useFormData';
import { FormStep } from '../../api/types';
import { StepComponents } from './StepComponents';
import { Spinner, ErrorMessage } from '../common';
import './styles.css';

interface UserFormProps {
  formId: string;
  onComplete?: (data: any) => void;
}

export const UserForm: React.FC = ({ formId, onComplete }) => {
  const { data, loading, error, saveData } = useFormData(formId);
  const [currentStep, setCurrentStep] = useState('personal');

  // Обработчик перехода к следующему шагу
  const handleNext = async (stepData: any) => {
    try {
      // Сохраняем данные текущего шага
      await saveData({
        data: { ...data?.data, ...stepData },
        step: getNextStep(currentStep)
      });
      
      // Переходим к следующему шагу
      setCurrentStep(getNextStep(currentStep));
    } catch (err) {
      console.error('Failed to save step:', err);
    }
  };

  if (loading) return ;
  if (error) return ;
  if (!data) return null;

  const StepComponent = StepComponents[currentStep];

  return (
    
{/* Индикатор прогресса */} {/* Текущий шаг формы */} setCurrentStep(getPreviousStep(currentStep))} isValid={!data.validationErrors?.length} />
); };

И наконец-то раздельное тестирование компонент. Здесь пройдемся по коду подробней.

// components/UserForm/UserForm.test.tsx
// Тесты компонента формы UserForm

import { render, screen, waitFor, fireEvent } from '@testing-library/react'; // Импортируем необходимые функции из библиотеки тестирования
import { UserForm } from './UserForm'; // Импортируем тестируемый компонент
import { UserFormApiClient } from '../../api/userFormApi'; // Импортируем API клиент

// Мокируем API клиент UserFormApiClient. 
// Это необходимо для изоляции компонента UserForm от реальных запросов к API.
// Jest заменит реальный UserFormApiClient на мок-реализацию.
jest.mock('../../api/userFormApi');

describe('UserForm', () => { // Описываем набор тестов для компонента UserForm
  // Функция, которая выполняется перед каждым тестом (beforeEach).
  // Здесь мы настраиваем мок-реализацию API клиента.
  beforeEach(() => {
    // mockImplementation используется для создания мок-функций.
    (UserFormApiClient as jest.Mock).mockImplementation(() => ({
      // Мокируем метод getFormData. Он будет возвращать промис, который резолвится с моковыми данными.
      getFormData: jest.fn().mockResolvedValue({
        id: 'test',
        step: 'personal',
        data: {},
        status: 'draft'
      }),
      // Мокируем метод saveFormData. Он также будет возвращать промис, который резолвится с моковыми данными.
      // Важно, что данные, переданные в saveFormData, будут возвращены обратно, имитируя сохранение.
      saveFormData: jest.fn().mockImplementation(async (id, data) => ({
        id,
        ...data, // Распространяем переданные данные в моковый ответ
        status: 'draft'
      }))
    }));
  });

  // Тест: проверка начального состояния загрузки (отображение спиннера).
  it('shows loading state initially', () => {
    render(); // Рендерим компонент UserForm. formId обязательный пропс.
    expect(screen.getByTestId('spinner')).toBeInTheDocument(); // Проверяем, что на экране отображается элемент со атрибутом data-testid="spinner" (спиннер).
  });

  // Тест: проверка навигации по форме.
  it('handles form navigation', async () => {
    render(); // Рендерим компонент

    // Ждем, пока компонент загрузится и на экране появится элемент с data-testid="personal-step".
    // waitFor используется для ожидания асинхронных операций.
    await waitFor(() => {
      expect(screen.getByTestId('personal-step')).toBeInTheDocument(); // Проверяем, что отобразился шаг "personal"
    });

    // Эмулируем ввод данных в поле с data-testid="name-input".
    fireEvent.change(screen.getByTestId('name-input'), {
      target: { value: 'John' } // Вводим значение "John"
    });
    // Эмулируем клик по кнопке "Next".
    fireEvent.click(screen.getByText('Next'));

    // Ожидаем, пока произойдет переход на следующий шаг (появление элемента с data-testid="address-step").
    await waitFor(() => {
      expect(screen.getByTestId('address-step')).toBeInTheDocument(); // Проверяем, что отобразился шаг "address"
    });
  });
});

вот! мы и добрались до преимуществ компонентного подхода:

Преимущества предлагаемой архитектуры:

  • Разделение ответственности (SoC):  Каждый модуль отвечает за свою область, что упрощает разработку и поддержку.

  • Тестируемость:  Легко проводить Unit-тестирование компонентов и API.

  • Масштабируемость:  Возможность расширения функциональности и добавления новых модулей.

  • Переиспользование кода:  Общие типы и константы используются как на Frontend, так и на Backend.

  • Параллельная разработка:  Frontend и Backend могут разрабатываться независимо.

© Habrahabr.ru