【翻译】React中的依赖倒置:构建真正可测试的组件

17 阅读3分钟

原文链接:cekrem.github.io/posts/depen…

作者:Christian Ekrem

在 React 开发领域,我们常会发现自己编写的组件与其依赖项紧密耦合。这使得测试变得困难、维护充满挑战,甚至几乎无法进行变更。依赖倒置原则(DIP)为我们提供了一条出路,但如何在 React 中有效应用它呢?

注:若需了解更侧重后端的依赖倒置实践,可参阅我先前关于Go语言中使用插件实现依赖倒置的文章。

问题:React中的紧密耦合

考虑这个常见场景:

const UserProfile = () => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch("/api/user")
      .then((res) => res.json())
      .then((data) => {
        setUser(data);
        setLoading(false);
      });
  }, []);

  if (loading) return <LoadingSpinner />;
  return <UserDetails user={user} />;
};

该组件存在以下问题:

  • 与fetch API耦合过紧
  • 因直接调用API导致难以测试
  • 数据源难以变更
  • 无法轻松测试加载状态

解决方案:依赖倒置

依赖倒置原则指出,高层模块不应依赖低层模块,两者都应依赖抽象层。在 React 中,这意味着组件应依赖接口而非具体实现。

让我们看看如何重构代码:

interface UserRepository {
  getUser: () => Promise<User>;
}

const UserProfile = ({
  userRepository,
}: {
  userRepository: UserRepository;
}) => {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    userRepository.getUser().then((data) => {
      setUser(data);
      setLoading(false);
    });
  }, [userRepository]);

  if (loading) return <LoadingSpinner />;
  return <UserDetails user={user} />;
};

(当然,你可以考虑将所有这些状态管理和useEffect逻辑提取到自定义钩子中,但这已超出当前讨论范围。)

实现存储库

现在我们可以创建存储库的具体实现:

class ApiUserRepository implements UserRepository {
  async getUser(): Promise<User> {
    const response = await fetch("/api/user");
    return response.json();
  }
}

class MockUserRepository implements UserRepository {
  private resolveUser: (user: User) => void = () => {};
  private rejectUserPromise: (error: Error) => void = () => {};

  getUser(): Promise<User> {
    return new Promise((resolve, reject) => {
      this.resolveUser = resolve;
      this.rejectUserPromise = reject;
    });
  }

  // Helper method to resolve the promise
  resolveWithUser(user: User) {
    this.resolveUser(user);
  }

  // Helper method to reject the promise
  rejectUser(error: Error) {
    this.rejectUserPromise(error);
  }
}

测试变得简单

采用这种结构,测试变得直截了当:

describe("UserProfile", () => {
  it("shows loading state initially", () => {
    const mockRepo = new MockUserRepository();
    render(<UserProfile userRepository={mockRepo} />);
    expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
  });

  it("displays user data when loaded", async () => {
    const mockRepo = new MockUserRepository();
    render(<UserProfile userRepository={mockRepo} />);

    // Simulate data fetching
    mockRepo.resolveWithUser({
      id: 1,
      name: "Test User",
      email: "test@example.com",
    });

    const userData = await screen.findByText("Test User");
    expect(userData).toBeInTheDocument();
  });

  // Testing exceptions would be equally stragihtforward, but excluded for brevity
});

最佳实践

  1. 定义清晰接口:创建代表依赖关系的接口
  2. 注入依赖:通过 props 或上下文传递依赖,更优方案是使用 TSyringe
  3. 隔离测试:每个组件应能在脱离依赖的情况下独立测试

结论

在 React 中应用依赖倒置原则可带来:

  • 更易测试的组件
  • 更轻松的维护
  • 更清晰的职责分离
  • 更灵活且可复用的代码

请谨记:目标并非增加复杂性,而是提升代码的可维护性与可测试性。从小处着手,在最合适的场景应用这些原则。

延伸阅读

关于依赖注入的说明

虽然本指南侧重于在 React 中应用依赖倒置原则,但我们不会深入探讨如何以简洁且可扩展的方式实现依赖注入。不过,若您希望进一步探索此领域,TSyringe 等库可作为在 React 应用中高效管理依赖关系的良好起点。