react-obsidian 0.0.32 → 0.0.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.json +3 -0
- package/README.md +21 -326
- package/dist/src/graph/registry/GraphResolveChain.d.ts +1 -1
- package/dist/src/graph/registry/GraphResolveChain.d.ts.map +1 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +5 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/injectors/hooks/HookInjector.js +1 -1
- package/dist/src/injectors/hooks/HookInjector.js.map +1 -1
- package/dist/src/observable/Observable.d.ts +12 -0
- package/dist/src/observable/Observable.d.ts.map +1 -0
- package/dist/src/observable/Observable.js +31 -0
- package/dist/src/observable/Observable.js.map +1 -0
- package/dist/src/observable/useObserver.d.ts +3 -0
- package/dist/src/observable/useObserver.d.ts.map +1 -0
- package/dist/src/observable/useObserver.js +18 -0
- package/dist/src/observable/useObserver.js.map +1 -0
- package/dist/src/types/index.d.ts +8 -8
- package/dist/src/types/index.d.ts.map +1 -1
- package/dist/src/utils/React.d.ts +1 -1
- package/dist/src/utils/React.d.ts.map +1 -1
- package/dist/test/fixtures/LifecycleBoundGraph.d.ts +7 -2
- package/dist/test/fixtures/LifecycleBoundGraph.d.ts.map +1 -1
- package/dist/test/fixtures/LifecycleBoundGraph.js +15 -3
- package/dist/test/fixtures/LifecycleBoundGraph.js.map +1 -1
- package/dist/test/fixtures/MainGraph.d.ts +1 -1
- package/dist/test/fixtures/MainGraph.d.ts.map +1 -1
- package/dist/transformers/babel-plugin-obsidian/helpers/index.d.ts +1 -1
- package/dist/transformers/babel-plugin-obsidian/helpers/index.d.ts.map +1 -1
- package/documentation/README.md +41 -0
- package/documentation/babel.config.js +3 -0
- package/documentation/blog/2019-05-28-first-blog-post.md +12 -0
- package/documentation/blog/2019-05-29-long-blog-post.md +44 -0
- package/documentation/blog/2021-08-01-mdx-blog-post.mdx +20 -0
- package/documentation/blog/2021-08-26-welcome/docusaurus-plushie-banner.jpeg +0 -0
- package/documentation/blog/2021-08-26-welcome/index.md +25 -0
- package/documentation/blog/authors.yml +17 -0
- package/documentation/docs/documentation/documentation.mdx +191 -0
- package/documentation/docs/documentation/installation.mdx +65 -0
- package/documentation/docs/documentation/meta/clearingGraphs.mdx +13 -0
- package/documentation/docs/documentation/meta/middlewares.mdx +27 -0
- package/documentation/docs/documentation/usage/ClassComponents.mdx +18 -0
- package/documentation/docs/documentation/usage/Classes.mdx +41 -0
- package/documentation/docs/documentation/usage/FunctionalComponents.mdx +57 -0
- package/documentation/docs/documentation/usage/Graphs.mdx +146 -0
- package/documentation/docs/documentation/usage/Hooks.mdx +85 -0
- package/documentation/docs/documentation/usage/ServiceLocator.mdx +38 -0
- package/documentation/docs/documentation/usage/_category_.json +9 -0
- package/documentation/docs/guides/configurableApplications.mdx +205 -0
- package/documentation/docs/guides/mockDependencies.mdx +141 -0
- package/documentation/docusaurus.config.js +146 -0
- package/documentation/package-lock.json +21290 -0
- package/documentation/package.json +46 -0
- package/documentation/sidebars.js +34 -0
- package/documentation/src/components/HomepageFeatures/index.tsx +71 -0
- package/documentation/src/components/HomepageFeatures/styles.module.css +11 -0
- package/documentation/src/css/custom.css +30 -0
- package/documentation/src/pages/index.module.css +23 -0
- package/documentation/src/pages/index.tsx +41 -0
- package/documentation/static/.nojekyll +0 -0
- package/documentation/static/img/api.svg +101 -0
- package/documentation/static/img/favicon.ico +0 -0
- package/documentation/static/img/logo.svg +265 -0
- package/documentation/static/img/obsidian.png +0 -0
- package/documentation/static/img/prototype.svg +1 -0
- package/documentation/static/img/stethoscope.svg +37 -0
- package/documentation/tsconfig.json +7 -0
- package/package.json +4 -4
- package/src/index.ts +3 -0
- package/src/injectors/hooks/HookInjector.ts +1 -1
- package/src/observable/Observable.ts +26 -0
- package/src/observable/useObserver.ts +17 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
---
|
|
2
|
+
sidebar_position: 1
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
## Introduction
|
|
6
|
+
|
|
7
|
+
In Object Oriented Programming, programs are organized around objects, where each object has a specific purpose. These objects can require other objects to perform their responsibilities. The required objects are called dependencies. Providing these dependencies manually is a tedious and error-prone process. The dependency injection pattern is a way to automate this process so you can focus on the logic of your application instead of writing boilerplate code.
|
|
8
|
+
|
|
9
|
+
Before you can inject dependencies into hooks, components and classes, the dependencies first need to be declared so Obsidian knows how to construct them. In Obsidian, dependencies are declared in classes called "Graphs". Graphs create a centralized place where dependencies are defined. This makes them a powerful tool for understanding the relationships between objects in your program.
|
|
10
|
+
|
|
11
|
+
## Declaring dependencies in a graph
|
|
12
|
+
The snippet below shows a basic example of a Graph. It defines two dependencies, `httpClient` and `databaseService`.
|
|
13
|
+
|
|
14
|
+
```ts title="ApplicationGraph.ts"
|
|
15
|
+
import {Singleton, Graph, ObjectGraph, Provides} from 'react-obsidian';
|
|
16
|
+
|
|
17
|
+
@Singleton() @Graph()
|
|
18
|
+
export class ApplicationGraph extends ObjectGraph {
|
|
19
|
+
@Provides()
|
|
20
|
+
httpClient(): HttpClient {
|
|
21
|
+
return new HttpClient();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@Provides()
|
|
25
|
+
databaseService(): DatabaseService {
|
|
26
|
+
return new DatabaseService();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Graphs must be annotated with the `@Graph` decorator. In this example we chose to annotate the class with the `@Singleton` decorator as well, which means that the graph and the dependencies it provides will only be constructed once.
|
|
32
|
+
|
|
33
|
+
Dependencies are constructed in methods annotated with the `@Provides` annotation. The `@Provides` annotation is used to tell Obsidian that the method is a dependency provider. From now on we'll refer to these methods as providers. Obsidian uses the provider's method name as the dependency's name. In this example, the `httpClient` provider method provides the `httpClient` dependency. The `databaseService` provider method provides the `databaseService` dependency.
|
|
34
|
+
|
|
35
|
+
Once your graph is declared you can use it to inject dependencies into the various constructs that form your application:
|
|
36
|
+
* [Inject hooks](/docs/documentation/usage/hooks#injecting-hooks)
|
|
37
|
+
* [Inject functional components](/docs/documentation/usage/FunctionalComponents)
|
|
38
|
+
* [Inject components](/docs/documentation/usage/ClassComponents)
|
|
39
|
+
* [Inject classes](/docs/documentation/usage/Classes)
|
|
40
|
+
|
|
41
|
+
:::info Did you know?
|
|
42
|
+
The term "graph" comes from [graph theory](https://en.wikipedia.org/wiki/Graph_theory). Obsidian constructs [Directed Acyclic Graphs](https://en.wikipedia.org/wiki/Directed_acyclic_graph) (DAGs) to represent the dependencies between objects. This type of graph ensures there are no circular dependencies between objects which cause call stack overflows and other unexpected bugs.
|
|
43
|
+
:::
|
|
44
|
+
|
|
45
|
+
## Specifying relationships between dependencies
|
|
46
|
+
Some of the services defined in your graphs may be independent, meaning they don't require any dependencies to be constructed. However, most of the time, services will require other services to perform their responsibilities. In these cases, you can specify the dependencies of a service as arguments in the provider and Obsidian will resolve them automatically.
|
|
47
|
+
|
|
48
|
+
```ts title="A graph that provides a service that depends on other services"
|
|
49
|
+
import {Singleton, Graph, ObjectGraph, Provides} from 'react-obsidian';
|
|
50
|
+
|
|
51
|
+
@Singleton() @Graph()
|
|
52
|
+
export class ApplicationGraph extends ObjectGraph {
|
|
53
|
+
@Provides()
|
|
54
|
+
httpClient(): HttpClient {
|
|
55
|
+
return new HttpClient();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@Provides()
|
|
59
|
+
databaseService(): DatabaseService {
|
|
60
|
+
return new DatabaseService();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@Provides()
|
|
64
|
+
appInitializer(httpClient: HttpClient, databaseService: DatabaseService): AppInitializer {
|
|
65
|
+
return new AppInitializer(httpClient, databaseService);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
:::info
|
|
71
|
+
Providers are evaluated lazily. This means that a provider is evaluated only when the dependency it provides is requested. Dependencies that are not used in the application, will never be constructed.
|
|
72
|
+
:::
|
|
73
|
+
|
|
74
|
+
## Graph types
|
|
75
|
+
|
|
76
|
+
There are two types of graphs in Obsidian: A singleton graph and a lifecycle-bound graph.
|
|
77
|
+
|
|
78
|
+
### The singleton graph
|
|
79
|
+
Applications typically have at least one singleton graph. These graphs are used to provide dependencies that are used throughout the application. These dependencies are usually singletons, which means they should only be constructed once. The `ApplicationGraph` in the [example above](/docs/documentation/usage/Graphs#specifying-relationships-between-dependencies) is a singleton graph.
|
|
80
|
+
|
|
81
|
+
To declare a singleton graph, annotate the graph class with the `@Singleton` decorator.
|
|
82
|
+
|
|
83
|
+
### The lifecycle-bound graph
|
|
84
|
+
Lifecycle-bound graphs are used to provide dependencies that are shared between components and hooks in a specific UI flow.
|
|
85
|
+
|
|
86
|
+
Dependencies provided by a lifecycle-bound graph are treated as singletons within the scope of the components or hooks that depend on that graph. When a component or hook that depends on a lifecycle-bound graph is mounted, Obsidian will reuse an existing instance of the graph if one exists. If no instance of the graph exists, Obsidian will construct a new instance of the graph. Once all components or hooks that use the graph are unmounted, the graph is destroyed.
|
|
87
|
+
|
|
88
|
+
In other words, dependencies provided by lifecycle-bound graphs are:
|
|
89
|
+
1. Constructed and destroyed when the flow starts and ends.
|
|
90
|
+
2. Shared between all components and hooks that take part in the flow.
|
|
91
|
+
|
|
92
|
+
#### Passing props to a lifecycle-bound graph
|
|
93
|
+
When a graph is created, it receives the props of the component or hook that requested it. This means that the graph can use the props to construct the dependencies it provides. The `@LifecycleBound` in the example below graph provides a `userService` which requires a `userId`. The `userId` is obtained from props.
|
|
94
|
+
|
|
95
|
+
```ts title="A lifecycle-bound graph"
|
|
96
|
+
import {LifecycleBound, Graph, ObjectGraph, Provides} from 'react-obsidian';
|
|
97
|
+
|
|
98
|
+
type HomeScreenProps {
|
|
99
|
+
userId: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@LifecycleBound() @Graph()
|
|
103
|
+
class HomeGraph extends ObjectGraph<HomeScreenProps> {
|
|
104
|
+
private userId: string;
|
|
105
|
+
|
|
106
|
+
construct(props: HomeScreenProps) {
|
|
107
|
+
super(props);
|
|
108
|
+
this.userId = props.userId;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@Provides()
|
|
112
|
+
userService(): UserService {
|
|
113
|
+
return new UserService(this.userId);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Typed dependencies
|
|
119
|
+
The `DependenciesOf` utility type creates a type of the dependencies provided by a graph. This type can be used to type the dependencies of hooks or props required by components. This utility type takes two arguments: the graph and a union of the keys of the dependencies we want to inject.
|
|
120
|
+
|
|
121
|
+
In this example we create a type called `ApplicationDependencies` which contains the dependencies `httpClient` and `databaseService` from the `ApplicationGraph` graph.
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
type ApplicationDependencies = DependenciesOf<ApplicationGraph, 'httpClient' | 'databaseService'>; // {httpClient: httpClient, databaseService: DatabaseService}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Graph composition
|
|
128
|
+
Graph composition is a powerful feature that allows you to create complex dependency graphs by combining smaller graphs. Composing graphs is useful when you want to reuse a graph in multiple places. For example, you might have a singleton graph that provides application-level dependencies. You might also have a lifecycle-bound graph that provides dependencies for a specific UI flow. You can compose these graphs together so that the lifecycle-bound graph can also inject the dependencies provided by the singleton graph.
|
|
129
|
+
|
|
130
|
+
To compose graphs, pass a `subgraphs` array to the `@Graph` decorator. The `subgraphs` array contains the graphs you want to "include" in your graph.
|
|
131
|
+
|
|
132
|
+
In the example below we declared a lifecycle-bound graph called `LoginGraph`. This graph provides a single dependency called `loginService` which has a dependency on `httpClient`. Since `httpClient` is exposed via the `ApplicationGraph`, we included it in the `subgraphs` array of our graph.
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
```ts title="LoginGraph.ts"
|
|
136
|
+
import {Graph, ObjectGraph, Provides} from 'react-obsidian';
|
|
137
|
+
import {ApplicationGraph} from './ApplicationGraph';
|
|
138
|
+
|
|
139
|
+
@LifecycleBound() @Graph({subgraphs: [ApplicationGraph]})
|
|
140
|
+
export class LoginGraph extends ObjectGraph {
|
|
141
|
+
@Provides()
|
|
142
|
+
loginService(httpClient: HttpClient): LoginService {
|
|
143
|
+
return new LoginService(httpClient);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
```
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
---
|
|
2
|
+
sidebar_position: 2
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
## Injecting hooks
|
|
6
|
+
|
|
7
|
+
Hooks are a fundamental construct in React. They allow you to plug into the lifecycle of a component or react to user interaction and execute code accordingly. As such, hooks typically need to interact with services where the actual logic is implemented.
|
|
8
|
+
|
|
9
|
+
Injecting hooks is simple and straightforward. Simply wrap your hook with the `injectHook` function and declare any required dependencies as destructured properties.
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
import { injectHook, DependenciesOf } from 'react-obsidian';
|
|
13
|
+
import { ApplicationGraph } from './ApplicationGraph';
|
|
14
|
+
|
|
15
|
+
const myHook = ({fooService, barService}: Props) => {
|
|
16
|
+
// do something with fooService and barService
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type Props = DependenciesOf<ApplicationGraph, 'fooService' | 'barService'>; // {fooService: FooService, barService: BarService}
|
|
20
|
+
|
|
21
|
+
export injectHook(myHook, ApplicationGraph);
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Strongly typed hooks
|
|
25
|
+
Writing strongly typed hooks is important to ensure that your hooks are easy to use and that you don't accidentally break them when refactoring.
|
|
26
|
+
|
|
27
|
+
### Combining injected dependencies and required arguments
|
|
28
|
+
Sometimes you want to inject hooks with dependencies, but also require additional arguments that will be passed from the calling scope. This is possible by using the `injectHookWithArguments` function.
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
import { injectHook, DependenciesOf } from 'react-obsidian';
|
|
32
|
+
import { ApplicationGraph } from './ApplicationGraph';
|
|
33
|
+
|
|
34
|
+
type Injected = DependenciesOf<ApplicationGraph, 'fooService'>; // {fooService: FooService}
|
|
35
|
+
type Own = {count: number};
|
|
36
|
+
|
|
37
|
+
const myHook = ({fooService, count}: Injected & Own) => {
|
|
38
|
+
// do something with fooService and count
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default injectHookWithArguments<Injected, Own>(myHook, ApplicationGraph);
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
When using `myHook`, we must pass the `count` argument otherwise we will get a type error. `fooService`, on the other hand, is optional and will be injected from the graph unless passed explicitly.
|
|
45
|
+
|
|
46
|
+
```tsx
|
|
47
|
+
import myHook from './myHook';
|
|
48
|
+
|
|
49
|
+
const MyComponent = () => {
|
|
50
|
+
const [count, setCount] = useState(1337);
|
|
51
|
+
myHook({count});
|
|
52
|
+
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Typing the return value of a hook
|
|
57
|
+
When using the standard `injectHook` function, the return value of the hook is inferred automatically by TypeScript. However, when using `injectHookWithArguments`, due to limitations of TypeScript's Generics system, the return value is not inferred and you'll have to specify it explicity.
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { injectHook, DependenciesOf } from 'react-obsidian';
|
|
61
|
+
import { ApplicationGraph } from './ApplicationGraph';
|
|
62
|
+
|
|
63
|
+
type Injected = DependenciesOf<ApplicationGraph, 'fooService'>;
|
|
64
|
+
type Own = {count: number};
|
|
65
|
+
type Result: {onPress: () => void};
|
|
66
|
+
|
|
67
|
+
const myHook = ({fooService, count}: Injected & Own): Result => {
|
|
68
|
+
const onPress = useCallback(() => {
|
|
69
|
+
fooService.doSomething(count);
|
|
70
|
+
}, [fooService, count]);
|
|
71
|
+
return {onPress};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export default injectHookWithArguments<Injected, Own, Result>(myHook, ApplicationGraph);
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Now the onPress callback is typed correctly when using the injected hook.
|
|
78
|
+
```tsx
|
|
79
|
+
import myHook from './myHook';
|
|
80
|
+
|
|
81
|
+
const MyComponent = () => {
|
|
82
|
+
const [count, setCount] = useState(1337);
|
|
83
|
+
const {onPress} = myHook({count});
|
|
84
|
+
}
|
|
85
|
+
```
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
sidebar_position: 5
|
|
3
|
+
title: "Service locator"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Obtaining dependencies imperatively
|
|
7
|
+
|
|
8
|
+
Obsidian is an Inversion of Control container. This means that it will automatically resolve dependencies for you, and you don't need to worry about how to obtain them. However, there are times when you need to obtain a dependency imperatively, for example when you need to pass a dependency to a third-party library that doesn't support dependency injection.
|
|
9
|
+
|
|
10
|
+
For these cases, you can obtain a graph instance and access the dependencies it provides imperatively. This is done by using the `Obsidian.obtain()` function which allows you to treat the graph as a Service Locator.
|
|
11
|
+
|
|
12
|
+
### Example
|
|
13
|
+
Consider the following graph which provides two dependencies: `fooService` and `barService` where `barService` depends on `fooService`:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import {Singleton, Graph, ObjectGraph, Provides} from 'react-obsidian';
|
|
17
|
+
|
|
18
|
+
@Singleton() @Graph()
|
|
19
|
+
export class SomeGraph extends ObjectGraph {
|
|
20
|
+
@Provides()
|
|
21
|
+
fooService(): FooService {
|
|
22
|
+
return new FooService();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@Provides()
|
|
26
|
+
barService(fooService: FooService): AppInitializer {
|
|
27
|
+
return new BarService(fooService);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Obtaining the dependencies directly from the graph is straight forward:
|
|
33
|
+
```ts Obtaining a dependency imperatively
|
|
34
|
+
Obsidian.obtain(ApplicationGraph).fooService();
|
|
35
|
+
|
|
36
|
+
// Even though barService depends on fooService, you don't need to provide its dependency
|
|
37
|
+
Obsidian.obtain(ApplicationGraph).barService();
|
|
38
|
+
```
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
---
|
|
2
|
+
sidebar_position: 2
|
|
3
|
+
title: 'Configurable applications'
|
|
4
|
+
toc_max_heading_level: 4
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
import Tabs from '@theme/Tabs';
|
|
8
|
+
import TabItem from '@theme/TabItem';
|
|
9
|
+
|
|
10
|
+
Designing applications to be flexible and configurable makes them more tolerable to changing requirements. The ability to change code frequently and quickly is one of the most important KPIs of any development team. This is generally made possible by a design that facilitates small pull requests, that modify a minimal amount of code across a minimal number of files.
|
|
11
|
+
|
|
12
|
+
The Dependency Injection pattern helps us write flexible code that is more tolerable to change by addressing three key concerns:
|
|
13
|
+
|
|
14
|
+
* How can a class be <ins>independent</ins> from the creation of the objects it depends on?
|
|
15
|
+
* How can an application, and the objects it uses support different <ins>configurations</ins>?
|
|
16
|
+
* How can the <ins>behavior</ins> of a piece of code be changed without editing it directly?
|
|
17
|
+
|
|
18
|
+
In this article we will learn how Obsidian can help us address these concerns.
|
|
19
|
+
|
|
20
|
+
## Configuring applications with providers
|
|
21
|
+
When using Obsidian, dependencies are declared and constructed in classes called Graphs. Each dependency is constructed by a method called a provider which acts as a **Seam**. Lets see how we can leverage them to make our apps flexible and configurable.
|
|
22
|
+
|
|
23
|
+
<!-- These providers act as **Seams**. -->
|
|
24
|
+
|
|
25
|
+
:::tip What are Seams?
|
|
26
|
+
A seam is a place where you can alter behavior in your program without editing in that place.
|
|
27
|
+
|
|
28
|
+
[Working Effectively with Legacy Code](https://www.amazon.com/Working-Effectively-Legacy-Michael-Feathers/dp/0131177052) by Michael Feathers
|
|
29
|
+
:::
|
|
30
|
+
|
|
31
|
+
### Example 1: Interchangeable dependencies according to external configurations
|
|
32
|
+
In this example we'll learn how to change the concrete object returned by a provider according to an external configuration. In a real life scenario, the external configuration would represent an A/B test or a feature toggle.
|
|
33
|
+
|
|
34
|
+
#### Step 1: Declare a graph
|
|
35
|
+
Lets declare a simple graph that provides a single dependency: an HTTP client used to make network requests.
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
@Singleton() @Graph()
|
|
39
|
+
class ApplicationGraph extends ObjectGraph {
|
|
40
|
+
@Provides()
|
|
41
|
+
httpClient(): HttpClient {
|
|
42
|
+
return new HttpClient();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Our `HttpClient` is using the standard `fetch` API to make network requests and for the sake of simplicity, it only supports `GET` and `POST` requests.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
class HttpClient {
|
|
51
|
+
async get(url: string): Promise<any> {
|
|
52
|
+
const response = await fetch(url, { method: 'GET' });
|
|
53
|
+
return await response.json();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async post(url: string, body: any): Promise<any> {
|
|
57
|
+
const response = fetch(url, { method: 'POST', body: JSON.stringify(body) });
|
|
58
|
+
return await response.json();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
#### Step 2: Implement another HTTP client
|
|
64
|
+
Just like our current HTTP client, the new client will only support `GET` and `POST` requests. The only difference is that it will use the `axios` library to make network requests.
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
class AxiosClient {
|
|
68
|
+
async get(url: string): Promise<any> {
|
|
69
|
+
const response = await axios.get(url);
|
|
70
|
+
return response.data;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async post(url: string, body: any): Promise<any> {
|
|
74
|
+
const response = await axios.post(url, body);
|
|
75
|
+
return response.data;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
#### Step 3: Make the clients interchangeable
|
|
81
|
+
To easily switch between the two clients, we'll use a well known principle called [Dependency Inversion](https://en.wikipedia.org/wiki/Dependency_inversion_principle). This principle states that high-level modules should not depend on low-level modules. Both the `HttpClient` and the `AxiosClient` are low-level modules, so we'll make the `ApplicationGraph` depend on an abstraction called `NetworkClient` instead.
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
interface NetworkClient {
|
|
85
|
+
get(url: string): Promise<any>;
|
|
86
|
+
post(url: string, body: any): Promise<any>;
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The two network clients will implement this interface:
|
|
91
|
+
<Tabs>
|
|
92
|
+
<TabItem value="http" label="HttpClient" default>
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
class HttpClient implements NetworkClient {
|
|
96
|
+
override async get(url: string): Promise<any> {
|
|
97
|
+
const response = await fetch(url, { method: 'GET' });
|
|
98
|
+
return await response.json();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
override async post(url: string, body: any): Promise<any> {
|
|
102
|
+
const response = fetch(url, { method: 'POST', body: JSON.stringify(body) });
|
|
103
|
+
return await response.json();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
</TabItem>
|
|
109
|
+
<TabItem value="axios" label="AxiosClient">
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
class AxiosClient implements NetworkClient {
|
|
113
|
+
override async get(url: string): Promise<any> {
|
|
114
|
+
const response = await axios.get(url);
|
|
115
|
+
return response.data.json;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
override async post(url: string, body: any): Promise<any> {
|
|
119
|
+
const response = await axios.post(url, body);
|
|
120
|
+
return response.data.json;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
</TabItem>
|
|
125
|
+
</Tabs>
|
|
126
|
+
|
|
127
|
+
#### Step 4: Return the correct client according to the configuration
|
|
128
|
+
To determine which client to return, we'll use a new dependency called `AppConfig` which will be used to access the application's configuration.
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
@Singleton() @Graph()
|
|
132
|
+
class ApplicationGraph extends ObjectGraph {
|
|
133
|
+
@Provides()
|
|
134
|
+
httpClient(appConfig: AppConfig): NetworkClient {
|
|
135
|
+
return appConfig.shouldUseAxiosClient() ? new AxiosClient() : new HttpClient();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
@Provides()
|
|
139
|
+
appConfig(): AppConfig {
|
|
140
|
+
return new AppConfig();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
We're done! Now we can easily control which network client to use according the application's configuration.
|
|
146
|
+
|
|
147
|
+
#### Conclusion and after thoughts
|
|
148
|
+
In this example we learned how to make dependencies interchangeable, and how to control which dependency to use according to an external configuration. This is a very common use case in large scale applications where we need an extra layer of assurance that changes can be easily rolled back in case of a bug.
|
|
149
|
+
|
|
150
|
+
Two important things to note about this example:
|
|
151
|
+
1. The provider's return type was changed to `NetworkClient` instead of `HttpClient`. This change could lead to further changes in the codebase, but it's a small price to pay for the flexibility it provides.
|
|
152
|
+
2. We wanted to keep the example short and easy to follow, so the two HTTP clients are simplified implementations of an actual client. They also share the same API which made it easy to implement the `NetworkClient` interface and have the two clients implement it. If the two clients had different APIs, perhaps because they supported typed request options and responses, then we would have to create common interfaces that would represent the common parts of the two APIs and adapters that would convert the two clients' APIs to the common API and vice versa.
|
|
153
|
+
|
|
154
|
+
### Example 2: Mocking dependencies in acceptance/integration tests
|
|
155
|
+
Acceptance and integration tests are a great way to test how an application behaves as a whole. In these type of tests, objects aren't mocked since we're testing how the objects behave when they interact with each other. But because tests also need to be predictable and stable, there are some operations that we do want to simulate. For example, we might want to mock a network client so that we don't make real network requests during the test as that would add an unwanted layer of unpredictability to the test.
|
|
156
|
+
|
|
157
|
+
In this example we'll learn how to mock a dependency and how to use that mocked instance across all objects involved in the test.
|
|
158
|
+
|
|
159
|
+
#### Step 1: Declare a graph
|
|
160
|
+
As in the previous example, we'll declare a simple graph that provides a single dependency: an HTTP client used to make network requests.
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
@Singleton() @Graph()
|
|
164
|
+
export class ApplicationGraph extends ObjectGraph {
|
|
165
|
+
@Provides()
|
|
166
|
+
httpClient(): HttpClient {
|
|
167
|
+
return new HttpClient();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
#### Step 2: Mock the HTTP client
|
|
173
|
+
To provide a mocked HTTP client to all objects involved in the test, we'll create a new graph that extends the `ApplicationGraph` and overrides the `httpClient` provider. In the next step we'll learn how to use this graph in our tests.
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
import { mock } from 'jest-mock-extended';
|
|
177
|
+
|
|
178
|
+
@Singleton() @Graph()
|
|
179
|
+
export class ApplicationGraphForTests extends ApplicationGraph {
|
|
180
|
+
@Provides()
|
|
181
|
+
override httpClient(): HttpClient {
|
|
182
|
+
return mock<HttpClient>();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
#### Step 3: Use the graph in the test
|
|
188
|
+
To use the graph in the test, we'll use Obsidian's test kit to use the `ApplicationGraphForTests` instead of the `ApplicationGraph` whenever it's needed.
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
import {testKit} from 'react-obsidian';
|
|
192
|
+
|
|
193
|
+
describe('Test suite', () => {
|
|
194
|
+
beforeEach(() => {
|
|
195
|
+
testKit.mockGraphs({
|
|
196
|
+
// Instruct Obsidian to use the ApplicationGraphForTests instead of the ApplicationGraph
|
|
197
|
+
ApplicationGraph: ApplicationGraphForTests,
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should do something', () => {
|
|
202
|
+
// ...
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
```
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
---
|
|
2
|
+
sidebar_position: 1
|
|
3
|
+
title: 'Mocking dependencies in unit tests'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Tests are an integral part of any software project. They let you verify that your code works as expected and that it doesn't break when you make changes. We want our tests to be as clear as possible so that developers don't have to waste time figuring out what the test is doing our how to fix it when it fails.
|
|
7
|
+
|
|
8
|
+
Obsidian promotes Object Oriented design, a paradigm that focuses on the relationships between objects and how they interact with each other. In this article we'll learn how adopting this approach lets us mock objects more easily and as a result improve the readability and maintainability of our tests.
|
|
9
|
+
|
|
10
|
+
:::tip On readable tests and developer velocity
|
|
11
|
+
"Every time the developers have to stop and puzzle through a test to figure out what it means, they have less time left to spend on creating new features, and the team velocity drops."
|
|
12
|
+
|
|
13
|
+
— [Growing Object-Oriented Software, Guided by Tests](https://www.amazon.com/Growing-Object-Oriented-Software-Guided-Tests/dp/0321503627)
|
|
14
|
+
:::
|
|
15
|
+
|
|
16
|
+
## The problem
|
|
17
|
+
The setup phase of a test is often the most complex part of the test. It involves creating test data, mocking dependencies and instantiating the unit-under-test. We've identified three common problems that make tests brittle, difficult to maintain, and hard to understand:
|
|
18
|
+
1. **Partial mocks** - a unit test is meant to test a single unit of code in isolation. If a dependency is partially mocked, our test is no longer testing a single unit. A bug in the partially mocked dependency can cause this unit test to fail preventing us from quickly identifying the root cause of the failure.
|
|
19
|
+
2. **Dependencies are introduced implicitly to the unit-under-test, usually via imports** - we should always create valid objects. If an object depends on another object, we should pass that dependency explicity in through the constructor. The constructor serves as the contract for the dependencies that a class requires to function. There's no point in creating partially working classes, and the constructor is used to enforces this constraint.
|
|
20
|
+
3. **Manual mocks** - manually mocking dependencies is a tedious and error prone process. It's easy to forget to mock a dependency, or to mock it incorrectly.
|
|
21
|
+
|
|
22
|
+
To illustrate these problems, let's look at a simple example.
|
|
23
|
+
|
|
24
|
+
```js showLineNumbers
|
|
25
|
+
describe('Example', () => {
|
|
26
|
+
const openURL = jest.fn();
|
|
27
|
+
|
|
28
|
+
let logger;
|
|
29
|
+
let foo;
|
|
30
|
+
let uut;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
// Problem 1: Partial mocks
|
|
34
|
+
logger = require('./Logger');
|
|
35
|
+
logger.log = jest.fn();
|
|
36
|
+
const spy = jest.spyOn(logger, 'warn');
|
|
37
|
+
|
|
38
|
+
// Problem 2: Implicit dependencies.
|
|
39
|
+
// Our UUT uses Linking.openUrl so we mock it on the module level.
|
|
40
|
+
jest.mock('react-native', () => ({
|
|
41
|
+
Linking: {
|
|
42
|
+
openURL
|
|
43
|
+
},
|
|
44
|
+
}));
|
|
45
|
+
// Problem 3: Manual mocks
|
|
46
|
+
foo = {
|
|
47
|
+
doSomething: jest.fn(),
|
|
48
|
+
}
|
|
49
|
+
uut = new Example(logger, foo);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## The solution
|
|
55
|
+
To achieve our goal of reducing boilerplate and improving readability, we'll refactor the above example as follows:
|
|
56
|
+
1. **Convert all dependencies to ES6 classes** - this will allow us to mock them using [jest-mock-extended](https://github.com/marchaos/jest-mock-extended) - a library that lets us create mock classes in a type-safe manner.
|
|
57
|
+
2. **Pass dependencies in through the constructor** - we'll pass the dependencies explicitly to the unit-under-test. This step will require us to declare new classes that will encapsulate interactions with third-party libraries.
|
|
58
|
+
|
|
59
|
+
<!-- In Object Oriented design, logic is encapsulated in classes that are introduced (injected) to one another in through the constructor. -->
|
|
60
|
+
<!-- This approach lets us use modern mocking techniques that require less boilerplate and are easier to reason about. -->
|
|
61
|
+
<!-- To achieve our goal, we'll do two things:
|
|
62
|
+
First,
|
|
63
|
+
First, refactor our code to use dependency injection.
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
To achieve our goal, we'll use an open-source library called [jest-mock-extended](https://github.com/marchaos/jest-mock-extended) which provides a `mock` function that creates a mock object with all the methods and properties of the original object. This means that we don't have to mock each method and property individually. -->
|
|
67
|
+
|
|
68
|
+
### Step 1: Encapsulate interactions with third-party dependencies
|
|
69
|
+
Implicit dependencies (dependencies introduced by importing a module) make it difficult to reason about the code and to test it. To avoid this problem, we'll create a new class that encapsulates interactions with the third-party library. We'll see how this approach lets us mock dependencies more easily.
|
|
70
|
+
|
|
71
|
+
```ts title="Encapsulating the Linking module in a new class responsible for opening URLs"
|
|
72
|
+
import {Linking} from 'react-native';
|
|
73
|
+
|
|
74
|
+
export class UrlOpener {
|
|
75
|
+
public async openUrl(url: string) {
|
|
76
|
+
if (await Linking.canOpenURL(url)) {
|
|
77
|
+
await Linking.openURL(url);
|
|
78
|
+
} else {
|
|
79
|
+
throw new Error(`Can't open URL: ${url}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
:::tip On decoupling third party dependencies
|
|
86
|
+
"Avoid littering direct calls to library classes in your code. You might think that you'll never change them, but that can become a self-fulfilling prophecy."
|
|
87
|
+
|
|
88
|
+
— [Working Effectively with Legacy Code](https://www.amazon.com/Working-Effectively-Legacy-Michael-Feathers/dp/0131177052)
|
|
89
|
+
:::
|
|
90
|
+
|
|
91
|
+
### Step 2: Convert dependencies to ES6 classes
|
|
92
|
+
We'll convert the `Logger` and `Foo` classes to ES6 classes:
|
|
93
|
+
|
|
94
|
+
```ts title="Logger.ts"
|
|
95
|
+
export class Logger {
|
|
96
|
+
public log(message: string) {
|
|
97
|
+
console.log(message);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
public warn(message: string) {
|
|
101
|
+
console.warn(message);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```ts title="Foo.ts"
|
|
107
|
+
export class Foo {
|
|
108
|
+
public doSomething() {
|
|
109
|
+
console.log('doing something...');
|
|
110
|
+
setTimeout(() => {
|
|
111
|
+
console.log('done!');
|
|
112
|
+
}, 1000);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Step 3: Mock dependencies using jest-mock-extended
|
|
118
|
+
When we mock a dependency using `jest-mock-extended`, we get a mock object that has all the methods and properties of the original object. This means that we don't have to mock each method and property individually. And, because we eliminated the implicit dependency on the `Linking` module, we can use this approach to mock it as well.
|
|
119
|
+
|
|
120
|
+
```ts showLineNumbers
|
|
121
|
+
import {mock} from 'jest-mock-extended';
|
|
122
|
+
import {Logger} from './Logger';
|
|
123
|
+
import {Foo} from './Foo';
|
|
124
|
+
|
|
125
|
+
describe('Example', () => {
|
|
126
|
+
let logger: Logger;
|
|
127
|
+
let foo: Foo;
|
|
128
|
+
let urlOpener: UrlOpener;
|
|
129
|
+
let uut: Example;
|
|
130
|
+
|
|
131
|
+
beforeEach(() => {
|
|
132
|
+
logger = mock<Logger>();
|
|
133
|
+
foo = mock<Foo>();
|
|
134
|
+
urlOpener = mock<UrlOpener>();
|
|
135
|
+
uut = new Example(logger, foo, urlOpener);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Wrapping up
|
|
141
|
+
While we didn't use any API from `Obsidian` in this refactor, this change was made possible due to how Obsidian influences the design of our code. Obsidian makes it easy to introduce classes to each other by passing them explicitly in through the constructor. This approach encourages us to split our code into smaller classes that are easier to test.
|