Repository: FormidableLabs/urql
Pull request #172 (2019-02-11 01:02:18)

Add tests for hooks
@kitten would appreciate if you could check out why the `useMutation` response isn't working in test.

Seems to be related to the `delay` operation.


Patch:
diff --git a/.travis.yml b/.travis.yml
index 81d713c5..0b1f587d 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -12,7 +12,13 @@ jobs:
     - stage: Test
       name: Lint
       node_js: 10
-      script: yarn run lint
+      script:
+        - yarn run lint
+    - stage: Test
+      name: Formatting
+      node_js: 10
+      script:
+        - yarn run check-formatting
     - stage: Test
       name: Jest (node:8)
       node_js: 8
@@ -36,7 +42,7 @@ jobs:
       node_js: 10
       script:
         - yarn run build
-        - yarn run bundlesize
+        # - yarn run bundlesize
     - stage: E2E
       name: Cypress
       node_js: 10
diff --git a/package.json b/package.json
index 41ec2e35..04106f6f 100644
--- a/package.json
+++ b/package.json
@@ -2,31 +2,27 @@
   "name": "urql",
   "version": "1.0.0-beta.1",
   "description": "A highly customizable and versatile GraphQL client for React",
-  "main": "dist/urql.js",
-  "umd:main": "dist/urql.umd.js",
-  "module": "dist/urql.es.js",
-  "typings": "lib/index.d.ts",
+  "main": "dist/cjs/index.js",
+  "module": "dist/esm/index.js",
+  "types": "dist/types/index.d.ts",
   "source": "src/index.ts",
   "files": [
-    "dist/urql.js",
-    "dist/urql.umd.js",
-    "dist/urql.es.js",
-    "lib",
+    "dist",
     "docs",
     "README.md",
     "CHANGELOG.md",
     "LICENSE"
   ],
   "scripts": {
-    "build:bundle": "microbundle build --no-sourcemap --target web",
-    "build:clean": "rimraf dist lib",
-    "build:ts": "tsc -d --emitDeclarationOnly --outDir lib",
-    "build": "run-s build:clean build:ts build:bundle",
+    "build": "run-s build:clean build:types build:cjs build:esm",
+    "build:clean": "rimraf dist",
+    "build:types": "tsc -p tsconfig.build.json -d --emitDeclarationOnly --outDir dist/types",
+    "build:cjs": "tsc -p tsconfig.build.json",
+    "build:esm": "tsc -p tsconfig.build.json --module es2015 --target es5 --outDir dist/esm",
     "test": "jest",
     "bundlesize": "bundlesize",
-    "lint:tslint": "tslint --project .",
-    "lint:prettier": "prettier --write src/**/*.{ts,tsx}",
-    "lint": "run-s lint:tslint lint:prettier",
+    "lint": "tslint --project .",
+    "check-formatting": "prettier --write src/**/*.{ts,tsx}",
     "prepublishOnly": "run-s build"
   },
   "author": "Formidable",
@@ -78,8 +74,8 @@
   },
   "bundlesize": [
     {
-      "path": "./dist/index.es.js",
-      "maxSize": "8 kB"
+      "path": "./dist/esm/**/*.js",
+      "maxSize": "1 kB"
     }
   ],
   "devDependencies": {
@@ -87,6 +83,7 @@
     "@types/graphql": "^14.0.5",
     "@types/jest": "^23.3.13",
     "@types/react": "16.8.2",
+    "@types/react-test-renderer": "^16.8.0",
     "bundlesize": "^0.17.0",
     "coveralls": "^3.0.0",
     "enzyme": "^3.8.0",
@@ -96,12 +93,11 @@
     "husky": "^1.2.0",
     "jest": "^23.6.0",
     "lint-staged": "^8.1.0",
-    "microbundle": "^0.9.0",
     "npm-run-all": "^4.1.5",
     "prettier": "^1.15.2",
     "react": "^16.8.1",
     "react-dom": "^16.8.1",
-    "react-test-renderer": "^16.2.0",
+    "react-test-renderer": "^16.8.1",
     "rimraf": "^2.6.2",
     "ts-jest": "^23.10.5",
     "tslint": "^5.11.0",
diff --git a/src/exchanges/cache.ts b/src/exchanges/cache.ts
index 5f5c9d3a..2e1e71dd 100755
--- a/src/exchanges/cache.ts
+++ b/src/exchanges/cache.ts
@@ -2,7 +2,7 @@ import { filter, map, merge, pipe, share, tap } from 'wonka';
 
 import { Client } from '../lib/client';
 import { formatTypeNames, gankTypeNamesFromResponse } from '../lib/typenames';
-import { Exchange, OperationResult, Operation, OperationType } from '../types';
+import { Exchange, Operation, OperationResult } from '../types';
 
 type ResultCache = Map<string, OperationResult>;
 interface OperationCache {
diff --git a/src/exchanges/compose.ts b/src/exchanges/compose.ts
index eb788d43..505ddbc4 100644
--- a/src/exchanges/compose.ts
+++ b/src/exchanges/compose.ts
@@ -1,4 +1,4 @@
-import { empty, map, pipe, Source } from 'wonka';
+import { map, pipe } from 'wonka';
 
 import { Client } from '../lib/client';
 import { Exchange, ExchangeIO, OperationResult } from '../types';
@@ -26,10 +26,7 @@ const fallbackIO: ExchangeIO = ops$ =>
 export const composeExchanges = (
   client: Client,
   exchanges: Exchange[]
-): ExchangeIO => {
-  const noopForward = () => empty as Source<OperationResult>;
-
-  return exchanges.reduceRight((forward, exchange) => {
+): ExchangeIO =>
+  exchanges.reduceRight((forward, exchange) => {
     return exchange({ client, forward });
   }, fallbackIO);
-};
diff --git a/src/exchanges/dedup.ts b/src/exchanges/dedup.ts
index 125136f9..87fe5829 100644
--- a/src/exchanges/dedup.ts
+++ b/src/exchanges/dedup.ts
@@ -1,5 +1,5 @@
 import { filter, pipe, tap } from 'wonka';
-import { Exchange, OperationType } from '../types';
+import { Exchange } from '../types';
 
 /** A default exchange for debouncing GraphQL requests. */
 export const dedupeExchange: Exchange = ({ forward }) => {
diff --git a/src/exchanges/fetch.ts b/src/exchanges/fetch.ts
index 9030a3dd..9ab056e8 100755
--- a/src/exchanges/fetch.ts
+++ b/src/exchanges/fetch.ts
@@ -1,15 +1,6 @@
-import {
-  filter,
-  make,
-  merge,
-  mergeMap,
-  pipe,
-  share,
-  Source,
-  takeUntil,
-} from 'wonka';
+import { filter, make, merge, mergeMap, pipe, share, takeUntil } from 'wonka';
 import { CombinedError } from '../lib/error';
-import { Exchange, OperationResult, Operation, OperationType } from '../types';
+import { Exchange, Operation, OperationResult } from '../types';
 
 /** A default exchange for fetching GraphQL requests. */
 export const fetchExchange: Exchange = ({ forward }) => {
diff --git a/src/exchanges/subscription.ts b/src/exchanges/subscription.ts
index 7bf79c59..4d31697b 100644
--- a/src/exchanges/subscription.ts
+++ b/src/exchanges/subscription.ts
@@ -13,11 +13,11 @@ import { CombinedError } from '../lib/error';
 
 import {
   Exchange,
-  OperationResult,
   ExecutionResult,
   GraphQLRequest,
   Operation,
   OperationContext,
+  OperationResult,
 } from '../types';
 
 export interface ObserverLike<T> {
diff --git a/src/lib/client.ts b/src/lib/client.ts
index 2609989b..10811064 100755
--- a/src/lib/client.ts
+++ b/src/lib/client.ts
@@ -1,34 +1,19 @@
-import {
-  filter,
-  makeSubject,
-  onEnd,
-  onStart,
-  pipe,
-  share,
-  Source,
-  take,
-} from 'wonka';
+import { filter, makeSubject, onStart, pipe, share, Source, take } from 'wonka';
 import { composeExchanges, defaultExchanges } from '../exchanges';
 import { hashString } from './hash';
 
 import {
   Exchange,
-  OperationResult,
   GraphqlMutation,
   GraphqlQuery,
   GraphQLRequest,
   GraphqlSubscription,
   Operation,
   OperationContext,
+  OperationResult,
   OperationType,
 } from '../types';
 
-interface SubscribeArgs {
-  next: (data: any) => void;
-  error: (err: any) => void;
-  complete: () => void;
-}
-
 /** Options for configuring the URQL [client]{@link Client}. */
 export interface ClientOptions {
   /** Target endpoint URL such as `https://my-target:8080/graphql`. */
@@ -102,10 +87,10 @@ export class Client implements ClientOptions {
   });
 
   /** Deletes an active operation's result observable and sends a teardown signal through the exchange pipeline */
-  private teardownOperation(operation: Operation) {
-    delete this.activeResultSources[operation.key];
-    this.dispatchOperation({ ...operation, operationName: 'teardown' });
-  }
+  // private teardownOperation(operation: Operation) {
+  //   delete this.activeResultSources[operation.key];
+  //   this.dispatchOperation({ ...operation, operationName: 'teardown' });
+  // }
 
   /** Executes an Operation by sending it through the exchange pipeline It returns an observable that emits all related exchange results and keeps track of this observable's subscribers. A teardown signal will be emitted when no subscribers are listening anymore. */
   executeRequestOperation(operation: Operation): Source<OperationResult> {
diff --git a/src/react/Mutation.tsx b/src/react/Mutation.tsx
index 970ab985..a53a625e 100644
--- a/src/react/Mutation.tsx
+++ b/src/react/Mutation.tsx
@@ -6,8 +6,7 @@ import { Consumer } from './context';
 interface MutationHandlerProps {
   client: Client;
   query: string;
-  variables?: object;
-  children: (arg: MutationHandlerState) => ReactNode;
+  children: (arg: MutationChildProps) => ReactNode;
 }
 
 interface MutationHandlerState {
@@ -16,6 +15,10 @@ interface MutationHandlerState {
   error?: CombinedError;
 }
 
+interface MutationChildProps extends MutationHandlerState {
+  executeMutation: (variables: object) => void;
+}
+
 class MutationHandler extends Component<
   MutationHandlerProps,
   MutationHandlerState
@@ -25,25 +28,13 @@ class MutationHandler extends Component<
   };
 
   public render() {
-    return this.props.children(this.state);
-  }
-
-  public componentDidMount() {
-    this.executeMutation();
-  }
-
-  public componentDidUpdate(oldProps) {
-    if (
-      this.props.query === oldProps.query ||
-      this.props.variables === oldProps.variables
-    ) {
-      return;
-    }
-
-    this.executeMutation();
+    return this.props.children({
+      ...this.state,
+      executeMutation: this.executeMutation,
+    });
   }
 
-  private async executeMutation() {
+  private executeMutation = async (variables: object) => {
     if (this.props.query === undefined) {
       return;
     }
@@ -55,20 +46,22 @@ class MutationHandler extends Component<
     });
 
     try {
-      const data = await pipe(
+      const { data, error } = await pipe(
         this.props.client.executeMutation(
-          createMutation(this.props.query, this.props.variables)
+          createMutation(this.props.query, variables)
         ),
         toPromise
       );
 
-      this.setState({ data });
+      this.setState({ fetching: false, data, error });
     } catch (error) {
       this.setState({
+        ...this.state,
+        fetching: false,
         error,
       });
     }
-  }
+  };
 }
 
 type MutationProps = Exclude<MutationHandlerProps, 'client'>;
diff --git a/src/react/Query.tsx b/src/react/Query.tsx
index 73c52c0c..8678d85d 100644
--- a/src/react/Query.tsx
+++ b/src/react/Query.tsx
@@ -24,18 +24,18 @@ class QueryHandler extends Component<QueryHandlerProps, QueryHandlerState> {
   };
 
   public componentDidMount() {
-    this.executeSubscription();
+    this.executeQuery();
   }
 
   public componentDidUpdate(oldProps) {
     if (
-      this.props.query === oldProps.query ||
+      this.props.query === oldProps.query &&
       this.props.variables === oldProps.variables
     ) {
       return;
     }
 
-    this.executeSubscription();
+    this.executeQuery();
   }
 
   public componentWillUnmount() {
@@ -48,7 +48,7 @@ class QueryHandler extends Component<QueryHandlerProps, QueryHandlerState> {
     return this.props.children(this.state);
   }
 
-  private executeSubscription() {
+  private executeQuery() {
     if (this.unsubscribe !== undefined) {
       this.unsubscribe();
     }
@@ -62,7 +62,7 @@ class QueryHandler extends Component<QueryHandlerProps, QueryHandlerState> {
     });
 
     const [unsubscribe] = pipe(
-      this.props.client.executeSubscription(
+      this.props.client.executeQuery(
         createQuery(this.props.query, this.props.variables)
       ),
       subscribe(({ data, error }) => {
diff --git a/src/react/context.test.tsx b/src/react/context.test.ts
similarity index 100%
rename from src/react/context.test.tsx
rename to src/react/context.test.ts
diff --git a/src/react/context.tsx b/src/react/context.ts
similarity index 71%
rename from src/react/context.tsx
rename to src/react/context.ts
index c8aeb65d..41ed741f 100644
--- a/src/react/context.tsx
+++ b/src/react/context.ts
@@ -1,10 +1,10 @@
 import createContext from 'create-react-context';
-import { Client } from '../lib/client';
+import { Client, createClient } from '../lib/client';
 
 // We assume some default options here; mainly not to actually be used
 // but not to error catastrophically if someone is just playing around
 const defaultOptions = { url: '/graphql' };
 
-export const Context = createContext<Client>(new Client(defaultOptions));
+export const Context = createContext<Client>(createClient(defaultOptions));
 export const Provider = Context.Provider;
 export const Consumer = Context.Consumer;
diff --git a/src/react/useMutation.ts b/src/react/useMutation.ts
index 8513df05..005be5b5 100644
--- a/src/react/useMutation.ts
+++ b/src/react/useMutation.ts
@@ -23,11 +23,11 @@ export const useMutation = <T = any>(query: string): UseMutationResponse<T> => {
     setState({ fetching: true, error: undefined, data: undefined });
 
     try {
-      const data = await pipe(
+      const { data, error } = await pipe(
         client.executeMutation(createMutation(query, variables)),
         toPromise
-      )[0];
-      setState({ ...state, fetching: false, data });
+      );
+      setState({ fetching: false, data, error });
     } catch (error) {
       setState({ ...state, fetching: false, error });
     }
diff --git a/src/react/useSubscription.ts b/src/react/useSubscription.ts
index 839d6595..df96270f 100644
--- a/src/react/useSubscription.ts
+++ b/src/react/useSubscription.ts
@@ -15,7 +15,7 @@ interface UseSubscriptionState<T> {
   error?: CombinedError;
 }
 
-type UseSubscriptionResponse<T> = [UseSubscriptionState<T>, () => void];
+type UseSubscriptionResponse<T> = [UseSubscriptionState<T>];
 
 export const useSubscription = <T = any, R = T>(
   args: UseSubscriptionArgs,
@@ -53,6 +53,5 @@ export const useSubscription = <T = any, R = T>(
     return () => executeUnsubscribe();
   }, [args.query, args.variables]);
 
-  // executeQuery === refetch
-  return [state, executeSubscription];
+  return [state];
 };
diff --git a/src/types.ts b/src/types.ts
index 5ec06e4a..22862c8b 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,4 +1,4 @@
-import { Observer, Source, Subject } from 'wonka';
+import { Source } from 'wonka';
 // import { ClientState } from './react';
 import { Client } from './lib/client';
 import { CombinedError } from './lib/error';
diff --git a/tsconfig.build.json b/tsconfig.build.json
new file mode 100644
index 00000000..ad7ede72
--- /dev/null
+++ b/tsconfig.build.json
@@ -0,0 +1,11 @@
+{
+  "extends": "./tsconfig.json",
+  "include": ["src/**/*.ts", "src/**/*.tsx"],
+  "exclude": [
+    "src/**/*.spec.ts",
+    "src/**/*.spec.tsx",
+    "src/**/*.test.ts",
+    "src/**/*.test.tsx",
+    "src/test-utils"
+  ]
+}
diff --git a/tsconfig.json b/tsconfig.json
index a741ee01..a9b925bc 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,26 +1,21 @@
 {
   "compilerOptions": {
     "esModuleInterop": true,
+    "noUnusedLocals": true,
     "rootDir": "./src",
     "baseUrl": ".",
-    "outDir": "lib",
+    "outDir": "dist/cjs",
     "lib": ["dom", "es2018", "esnext.asynciterable", "esnext.array"],
     "jsx": "react",
-    "declaration": true,
+    "declaration": false,
     "module": "es2015",
     "moduleResolution": "node",
-    "target": "es2017",
+    "target": "es5",
     "strict": true,
     "noImplicitAny": false,
     "noUnusedParameters": true,
+    "sourceMap": true,
     "pretty": true
   },
-  "include": ["src/**/*.ts", "src/**/*.tsx"],
-  "exclude": [
-    "src/**/*.spec.ts",
-    "src/**/*.spec.tsx",
-    "src/**/*.test.ts",
-    "src/**/*.test.tsx",
-    "src/test-utils"
-  ]
+  "include": ["src/**/*.ts", "src/**/*.tsx"]
 }
diff --git a/tslint.json b/tslint.json
index cfa84001..a18a5f8a 100755
--- a/tslint.json
+++ b/tslint.json
@@ -11,6 +11,7 @@
     "no-submodule-imports": false,
     "interface-name": false,
     "only-arrow-functions": false,
-    "no-object-literal-type-assertion": false
+    "no-object-literal-type-assertion": false,
+    "no-implicit-dependencies": [true, "dev"]
   }
 }
diff --git a/yarn.lock b/yarn.lock
index 2a31e1ed..63c6dae6 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -260,6 +260,13 @@
   resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.1.tgz#48fd98c1561fe718b61733daed46ff115b496e18"
   integrity sha512-eqz8c/0kwNi/OEHQfvIuJVLTst3in0e7uTKeuY+WL/zfKn0xVujOTp42bS/vUUokhK5P2BppLd9JXMOMHcgbjA==
 
+"@types/react-test-renderer@^16.8.0":
+  version "16.8.0"
+  resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-16.8.0.tgz#dbed6549f97a7f114b6920bf553a5db7e61bf83c"
+  integrity sha512-m563EQSTVB2g6h+FDUH2cgfiRdjL1KHVyi643EQQSFIblMPrWwJh/adqTcMS/FhJHvhEboR4pmhrhEXyHDDsmQ==
+  dependencies:
+    "@types/react" "*"
+
 "@types/react@*", "@types/react@16.8.2":
   version "16.8.2"
   resolved "https://registry.yarnpkg.com/@types/react/-/react-16.8.2.tgz#3b7a7f7ea89d1c7d68b00849fb5de839011c077b"
@@ -5839,7 +5846,7 @@ react-is@^16.7.0, react-is@^16.8.1:
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.1.tgz#a80141e246eb894824fb4f2901c0c50ef31d4cdb"
   integrity sha512-ioMCzVDWvCvKD8eeT+iukyWrBGrA3DiFYkXfBsVYIRdaREZuBjENG+KjrikavCLasozqRWTwFUagU/O4vPpRMA==
 
-react-test-renderer@^16.0.0-0, react-test-renderer@^16.2.0:
+react-test-renderer@^16.0.0-0, react-test-renderer@^16.8.1:
   version "16.8.1"
   resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.8.1.tgz#72845ad9269be526126e97853311982f781767be"
   integrity sha512-Bd21TN3+YVl6GZwav6O0T6m5UwGfOj+2+xZH5VH93ToD6M5uclN/c+R1DGX49ueG413KZPUx7Kw3sOYz2aJgfg==


Test patch:
diff --git a/src/exchanges/__snapshots__/fetch.test.ts.snap b/src/exchanges/__snapshots__/fetch.test.ts.snap
index 592d3716..b7159e80 100644
--- a/src/exchanges/__snapshots__/fetch.test.ts.snap
+++ b/src/exchanges/__snapshots__/fetch.test.ts.snap
@@ -1,6 +1,6 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`should return error data from fetch 1`] = `
+exports[`returns error data from fetch 1`] = `
 Object {
   "data": undefined,
   "error": CombinedError {
@@ -36,7 +36,7 @@ Object {
 }
 `;
 
-exports[`should return response data from fetch 1`] = `
+exports[`returns response data from fetch 1`] = `
 Object {
   "data": Object {
     "data": Object {
diff --git a/src/exchanges/cache.test.ts b/src/exchanges/cache.test.ts
index 5ef7d6ea..c272c1a3 100755
--- a/src/exchanges/cache.test.ts
+++ b/src/exchanges/cache.test.ts
@@ -1,13 +1,5 @@
-import {
-  forEach,
-  makeSubject,
-  map,
-  pipe,
-  publish,
-  Source,
-  Subject,
-} from 'wonka';
-
+import { makeSubject, map, pipe, publish, Source, Subject } from 'wonka';
+import { Client } from '../lib/client';
 import {
   mutationOperation,
   mutationResponse,
@@ -16,8 +8,6 @@ import {
   subscriptionOperation,
   subscriptionResult,
 } from '../test-utils';
-
-import { Client } from '../lib/client';
 import { Operation } from '../types';
 import { afterMutation, cacheExchange } from './cache';
 
diff --git a/src/exchanges/dedup.test.ts b/src/exchanges/dedup.test.ts
index badb0d42..e9aab98b 100644
--- a/src/exchanges/dedup.test.ts
+++ b/src/exchanges/dedup.test.ts
@@ -7,15 +7,12 @@ import {
   Source,
   Subject,
 } from 'wonka';
-
+import { Client } from '../lib/client';
 import {
   mutationOperation,
-  mutationResponse,
   queryOperation,
   queryResponse,
 } from '../test-utils';
-
-import { Client } from '../lib/client';
 import { Operation } from '../types';
 import { dedupeExchange } from './dedup';
 
diff --git a/src/exchanges/fetch.test.ts b/src/exchanges/fetch.test.ts
index d96cc08d..95800965 100755
--- a/src/exchanges/fetch.test.ts
+++ b/src/exchanges/fetch.test.ts
@@ -1,18 +1,7 @@
-import {
-  empty,
-  fromValue,
-  makeSubject,
-  merge,
-  never,
-  pipe,
-  Source,
-  subscribe,
-  toPromise,
-} from 'wonka';
-
+import { empty, fromValue, pipe, Source, subscribe, toPromise } from 'wonka';
 import { Client } from '../lib/client';
-import { queryOperation, subscriptionOperation } from '../test-utils';
-import { Exchange, OperationResult } from '../types';
+import { queryOperation } from '../test-utils';
+import { OperationResult } from '../types';
 import { fetchExchange } from './fetch';
 
 const fetch = (global as any).fetch as jest.Mock;
@@ -51,7 +40,7 @@ const exchangeArgs = {
   client: {} as Client,
 };
 
-it('should return response data from fetch', async () => {
+it('returns response data from fetch', async () => {
   fetch.mockResolvedValue({
     status: 200,
     json: jest.fn().mockResolvedValue(response),
@@ -66,7 +55,7 @@ it('should return response data from fetch', async () => {
   expect(data).toMatchSnapshot();
 });
 
-it('should return error data from fetch', async () => {
+it('returns error data from fetch', async () => {
   fetch.mockResolvedValue({
     status: 400,
     json: jest.fn().mockResolvedValue(response),
@@ -81,7 +70,7 @@ it('should return error data from fetch', async () => {
   expect(data).toMatchSnapshot();
 });
 
-it('should call cancel when the Observable is cancelled', () => {
+it('calls cancel when the Observable is cancelled', () => {
   fetch.mockReturnValue(Promise.reject(abortError));
 
   const [unsubscribe] = pipe(
diff --git a/src/exchanges/subscription.test.ts b/src/exchanges/subscription.test.ts
index 42284fc1..f3b88e69 100644
--- a/src/exchanges/subscription.test.ts
+++ b/src/exchanges/subscription.test.ts
@@ -1,22 +1,8 @@
-import {
-  empty,
-  fromValue,
-  never,
-  pipe,
-  publish,
-  Source,
-  take,
-  toPromise,
-} from 'wonka';
+import { empty, fromValue, pipe, Source, take, toPromise } from 'wonka';
 import { Client } from '../lib/client';
 import { subscriptionOperation, subscriptionResult } from '../test-utils';
-import { OperationResult, Operation } from '../types';
-
-import {
-  subscriptionExchange,
-  SubscriptionForwarder,
-  SubscriptionOperation,
-} from './subscription';
+import { OperationResult } from '../types';
+import { subscriptionExchange, SubscriptionForwarder } from './subscription';
 
 const exchangeArgs = {
   forward: () => empty as Source<OperationResult>,
diff --git a/src/lib/__snapshots__/hash.test.ts.snap b/src/lib/__snapshots__/hash.test.ts.snap
new file mode 100644
index 00000000..9427cef7
--- /dev/null
+++ b/src/lib/__snapshots__/hash.test.ts.snap
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`hash should returned a murmur hashed string from a query string 1`] = `"1rvkz44"`;
diff --git a/src/lib/client.test.ts b/src/lib/client.test.ts
index 3753cbd0..1ce159e8 100755
--- a/src/lib/client.test.ts
+++ b/src/lib/client.test.ts
@@ -2,12 +2,10 @@
 jest.mock('./hash', () => ({
   hashString: () => 'hash',
 }));
-import { map, pipe, share, subscribe, switchAll, tap, toPromise } from 'wonka';
+import { map, pipe, subscribe, tap } from 'wonka';
 import { createClient } from './client';
-import { CombinedError } from './error';
 
 const url = 'https://hostname.com';
-const fetch = (global as any).fetch as jest.Mock;
 
 describe('createClient', () => {
   it('passes snapshot', () => {
@@ -50,15 +48,17 @@ beforeEach(() => {
   receivedOps = [];
   exchangeMock.mockClear();
   receiveMock.mockClear();
-  client = createClient({ url, exchanges: [exchangeMock] });
+  client = createClient({ url, exchanges: [exchangeMock] as any[] });
 });
 
 describe('exchange args', () => {
   it('receives forward function', () => {
+    // @ts-ignore
     expect(typeof exchangeMock.mock.calls[0][0].forward).toBe('function');
   });
 
   it('recieves client', () => {
+    // @ts-ignore
     expect(exchangeMock.mock.calls[0][0]).toHaveProperty('client', client);
   });
 });
diff --git a/src/lib/hash.test.ts b/src/lib/hash.test.ts
index 6f2254af..dca7c57f 100644
--- a/src/lib/hash.test.ts
+++ b/src/lib/hash.test.ts
@@ -2,7 +2,6 @@ import { hashString } from './hash';
 
 describe('hash', () => {
   it('should returned a murmur hashed string from a query string', () => {
-    let hash = hashString(`{ todos { id } }`);
-    expect(hash).toBe('1rvkz44');
+    expect(hashString(`{ todos { id } }`)).toMatchSnapshot();
   });
 });
diff --git a/src/react/Mutation.test.tsx b/src/react/Mutation.test.tsx
new file mode 100644
index 00000000..51f522d0
--- /dev/null
+++ b/src/react/Mutation.test.tsx
@@ -0,0 +1,120 @@
+jest.mock('./context', () => {
+  const c = {
+    executeMutation: jest.fn(),
+  };
+
+  return {
+    client: c,
+    Consumer: (p: any) => p.children(client),
+  };
+});
+
+import { mount } from 'enzyme';
+import React from 'react';
+import { fromValue } from 'wonka';
+// @ts-ignore - client is exclusively from mock
+import { client } from './context';
+import { Mutation } from './Mutation';
+
+const props = {
+  query: 'examplequery',
+};
+let childProps: any;
+
+const childMock = (c: any) => {
+  childProps = c;
+  return null;
+};
+let activeWrapper: any;
+const mountWrapper = p => {
+  activeWrapper = mount(<Mutation {...p} children={childMock} />);
+  return activeWrapper;
+};
+
+beforeEach(() => {
+  client.executeMutation.mockClear();
+  childProps = undefined;
+});
+
+afterEach(() => {
+  if (activeWrapper !== undefined) {
+    activeWrapper.unmount();
+  }
+});
+
+describe('on init', () => {
+  it('default values match snapshot', () => {
+    mountWrapper(props);
+    expect(childProps).toMatchSnapshot();
+  });
+});
+
+describe('execute mutation', () => {
+  it('calls executeMutation', () => {
+    mountWrapper(props);
+    childProps.executeMutation();
+    expect(client.executeMutation).toBeCalledTimes(1);
+  });
+
+  it('calls executeMutation with query', () => {
+    mountWrapper(props);
+    childProps.executeMutation();
+    expect(client.executeMutation.mock.calls[0][0]).toHaveProperty(
+      'query',
+      props.query
+    );
+  });
+
+  it('calls executeMutation with variables', () => {
+    const vars = { test: 1234 };
+    mountWrapper(props);
+    childProps.executeMutation(vars);
+    expect(client.executeMutation.mock.calls[0][0]).toHaveProperty(
+      'variables',
+      vars
+    );
+  });
+});
+
+describe('on execute', () => {
+  it('sets fetching to true', () => {
+    client.executeMutation.mockReturnValue(new Promise(() => undefined));
+
+    mountWrapper(props);
+    childProps.executeMutation();
+
+    expect(childProps.fetching).toBe(true);
+  });
+
+  it('returns data from client', done => {
+    const data = 1234;
+    client.executeMutation.mockReturnValue(
+      fromValue({ data, error: undefined })
+    );
+
+    mountWrapper(props);
+    childProps.executeMutation();
+
+    // This should be synchronous...
+    setTimeout(() => {
+      expect(childProps.data).toBe(data);
+      done();
+    }, 300);
+  });
+
+  it('returns error from client', done => {
+    const error = Error('Error here');
+    client.executeMutation.mockReturnValue(
+      fromValue({ data: undefined, error })
+    );
+
+    mountWrapper(props);
+    childProps.executeMutation();
+
+    // This should be synchronous...
+    setTimeout(() => {
+      expect(childProps.error).toBe(error);
+      done();
+    }, 300);
+  });
+});
diff --git a/src/react/Query.test.tsx b/src/react/Query.test.tsx
new file mode 100644
index 00000000..b0cf7d80
--- /dev/null
+++ b/src/react/Query.test.tsx
@@ -0,0 +1,112 @@
+jest.mock('./context', () => {
+  const c = {
+    executeQuery: jest.fn(),
+  };
+
+  return {
+    client: c,
+    Consumer: (p: any) => p.children(client),
+  };
+});
+
+import { mount, shallow } from 'enzyme';
+import React from 'react';
+import { delay, fromValue, pipe } from 'wonka';
+// @ts-ignore - client is exclusively from mock
+import { client } from './context';
+import { Query } from './Query';
+
+const props = {
+  query: 'examplequery',
+};
+let childProps: any;
+
+const childMock = (c: any) => {
+  childProps = c;
+  return <h1>mock</h1>;
+};
+const mountWrapper = (p, isShallow = false) => {
+  if (isShallow) {
+    return shallow(<Query {...p} children={childMock} />);
+  }
+
+  const w = mount(<Query {...p} children={childMock} />);
+  return w;
+};
+
+beforeEach(() => {
+  client.executeQuery.mockClear();
+  childProps = undefined;
+});
+
+describe('on init', () => {
+  beforeEach(() => {
+    client.executeQuery.mockReturnValue(fromValue({ data: 1234 }));
+  });
+
+  it('default values match snapshot', () => {
+    mountWrapper(props);
+    expect(childProps).toMatchSnapshot();
+  });
+
+  it('calls executeQuery', () => {
+    mountWrapper(props);
+    expect(client.executeQuery).toBeCalledTimes(1);
+  });
+});
+
+describe('on change', () => {
+  beforeEach(() => {
+    client.executeQuery.mockReturnValue(fromValue({ data: 1234 }));
+  });
+
+  it('executes new query', () => {
+    const wrapper = mountWrapper(props);
+
+    // @ts-ignore
+    wrapper.setProps({ ...props, query: 'new query' });
+    expect(client.executeQuery).toBeCalledTimes(2);
+  });
+});
+
+describe('on fetch', () => {
+  beforeAll(() => {
+    client.executeQuery.mockReturnValue(
+      pipe(
+        fromValue({ data: 1234 }),
+        delay(1234)
+      )
+    );
+  });
+
+  it('sets fetching to true', () => {
+    mountWrapper(props);
+    expect(childProps).toHaveProperty('fetching', true);
+  });
+});
+
+describe('on data', () => {
+  const data = 12345;
+
+  beforeAll(() => {
+    client.executeQuery.mockReturnValue(fromValue({ data }));
+  });
+
+  it('returns data', () => {
+    mountWrapper(props);
+    expect(childProps).toHaveProperty('data', data);
+  });
+});
+
+describe('on error', () => {
+  const error = Error('error here');
+
+  beforeAll(() => {
+    client.executeQuery.mockReturnValue(fromValue({ error }));
+  });
+
+  it('returns error', () => {
+    mountWrapper(props);
+    expect(childProps).toHaveProperty('error', error);
+  });
+});
diff --git a/src/react/__snapshots__/Mutation.test.tsx.snap b/src/react/__snapshots__/Mutation.test.tsx.snap
new file mode 100644
index 00000000..dfb7a029
--- /dev/null
+++ b/src/react/__snapshots__/Mutation.test.tsx.snap
@@ -0,0 +1,8 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`on init default values match snapshot 1`] = `
+Object {
+  "executeMutation": [Function],
+  "fetching": false,
+}
+`;
diff --git a/src/react/__snapshots__/Query.test.tsx.snap b/src/react/__snapshots__/Query.test.tsx.snap
new file mode 100644
index 00000000..02c4b51d
--- /dev/null
+++ b/src/react/__snapshots__/Query.test.tsx.snap
@@ -0,0 +1,9 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`on init default values match snapshot 1`] = `
+Object {
+  "data": 1234,
+  "error": undefined,
+  "fetching": false,
+}
+`;
diff --git a/src/react/__snapshots__/context.test.tsx.snap b/src/react/__snapshots__/context.test.ts.snap
similarity index 100%
rename from src/react/__snapshots__/context.test.tsx.snap
rename to src/react/__snapshots__/context.test.ts.snap
diff --git a/src/react/__snapshots__/useMutation.test.tsx.snap b/src/react/__snapshots__/useMutation.test.tsx.snap
new file mode 100644
index 00000000..ab88df2e
--- /dev/null
+++ b/src/react/__snapshots__/useMutation.test.tsx.snap
@@ -0,0 +1,9 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`on initial useEffect initialises default state 1`] = `
+Object {
+  "data": undefined,
+  "error": undefined,
+  "fetching": false,
+}
+`;
diff --git a/src/react/__snapshots__/useQuery.test.tsx.snap b/src/react/__snapshots__/useQuery.test.tsx.snap
new file mode 100644
index 00000000..ab88df2e
--- /dev/null
+++ b/src/react/__snapshots__/useQuery.test.tsx.snap
@@ -0,0 +1,9 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`on initial useEffect initialises default state 1`] = `
+Object {
+  "data": undefined,
+  "error": undefined,
+  "fetching": false,
+}
+`;
diff --git a/src/react/__snapshots__/useSubscription.test.tsx.snap b/src/react/__snapshots__/useSubscription.test.tsx.snap
new file mode 100644
index 00000000..50debc96
--- /dev/null
+++ b/src/react/__snapshots__/useSubscription.test.tsx.snap
@@ -0,0 +1,8 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`on initial useEffect initialises default state 1`] = `
+Object {
+  "data": undefined,
+  "error": undefined,
+}
+`;
diff --git a/src/react/useMutation.test.tsx b/src/react/useMutation.test.tsx
new file mode 100644
index 00000000..49826f90
--- /dev/null
+++ b/src/react/useMutation.test.tsx
@@ -0,0 +1,134 @@
+// Note: Testing for hooks is not yet supported in Enzyme - https://github.com/airbnb/enzyme/issues/2011
+jest.mock('../lib/client', () => {
+  const d = { data: 1234, error: 5678 };
+  const { delay, fromValue, pipe } = require('wonka');
+  const mock = {
+    executeMutation: jest.fn(() =>
+      pipe(
+        fromValue({ data: 1, error: 2 }),
+        delay(200)
+      )
+    ),
+  };
+
+  return {
+    createClient: () => mock,
+    data: d,
+  };
+});
+
+import React, { FC } from 'react';
+import renderer, { act } from 'react-test-renderer';
+// @ts-ignore - data is imported from mock only
+import { createClient } from '../lib/client';
+import { useMutation } from './useMutation';
+
+// @ts-ignore
+const client = createClient() as { executeMutation: jest.Mock };
+const props = {
+  query: 'example query',
+};
+let state: any;
+let execute: any;
+
+const MutationUser: FC<typeof props> = ({ query }) => {
+  const [s, e] = useMutation(query);
+  state = s;
+  execute = e;
+  return <p>{s.data}</p>;
+};
+
+beforeAll(() => {
+  // tslint:disable-next-line
+  console.log(
+    'supressing console.error output due to react-test-renderer spam (hooks related)'
+  );
+  jest.spyOn(global.console, 'error').mockImplementation();
+});
+
+beforeEach(() => {
+  client.executeMutation.mockClear();
+  state = undefined;
+  execute = undefined;
+});
+
+describe('on initial useEffect', () => {
+  it('initialises default state', () => {
+    renderer.create(<MutationUser {...props} />);
+    expect(state).toMatchSnapshot();
+  });
+
+  it('does not execute subscription', () => {
+    renderer.create(<MutationUser {...props} />);
+    expect(client.executeMutation).toBeCalledTimes(0);
+  });
+});
+
+describe('on execute', () => {
+  const vars = { test: 1234 };
+
+  it('sets fetching to true', () => {
+    renderer.create(<MutationUser {...props} />);
+    act(() => {
+      execute(vars);
+    });
+    expect(state).toHaveProperty('fetching', true);
+  });
+
+  it('calls executeMutation', () => {
+    renderer.create(<MutationUser {...props} />);
+    act(() => {
+      execute(vars);
+    });
+    expect(client.executeMutation).toBeCalledTimes(1);
+  });
+
+  it('calls executeMutation with query', () => {
+    renderer.create(<MutationUser {...props} />);
+    act(() => {
+      execute(vars);
+    });
+    expect(client.executeMutation.mock.calls[0][0]).toHaveProperty(
+      'query',
+      props.query
+    );
+  });
+
+  it('calls executeMutation with variables', () => {
+    renderer.create(<MutationUser {...props} />);
+    act(() => {
+      execute(vars);
+    });
+    expect(client.executeMutation.mock.calls[0][0]).toHaveProperty(
+      'variables',
+      vars
+    );
+  });
+});
+
+describe('on subscription update', () => {
+  it('forwards data response', async () => {
+    const wrapper = renderer.create(<MutationUser {...props} />);
+    await execute();
+    wrapper.update(<MutationUser {...props} />);
+
+    expect(state).toHaveProperty('data', 1);
+  });
+
+  it('forwards error response', async () => {
+    const wrapper = renderer.create(<MutationUser {...props} />);
+    await execute();
+    wrapper.update(<MutationUser {...props} />);
+
+    expect(state).toHaveProperty('error', 2);
+  });
+
+  it('sets fetching to false', async () => {
+    const wrapper = renderer.create(<MutationUser {...props} />);
+    wrapper.update(<MutationUser {...props} />);
+
+    await execute();
+    wrapper.update(<MutationUser {...props} />);
+    expect(state).toHaveProperty('fetching', false);
+  });
+});
diff --git a/src/react/useQuery.test.tsx b/src/react/useQuery.test.tsx
new file mode 100644
index 00000000..880baec9
--- /dev/null
+++ b/src/react/useQuery.test.tsx
@@ -0,0 +1,153 @@
+// Note: Testing for hooks is not yet supported in Enzyme - https://github.com/airbnb/enzyme/issues/2011
+jest.mock('../lib/client', () => {
+  const d = { data: 1234, error: 5678 };
+  const { map, interval, pipe } = require('wonka');
+  const mock = {
+    executeQuery: jest.fn(() =>
+      pipe(
+        interval(400),
+        map(i => ({ data: i, error: i + 1 }))
+      )
+    ),
+  };
+
+  return {
+    createClient: () => mock,
+    data: d,
+  };
+});
+
+import React, { FC } from 'react';
+import renderer, { act } from 'react-test-renderer';
+// @ts-ignore - data is imported from mock only
+import { createClient } from '../lib/client';
+import { useQuery } from './useQuery';
+
+// @ts-ignore
+const client = createClient() as { executeQuery: jest.Mock };
+const props = {
+  query: 'example query',
+  variables: {
+    myVar: 1234,
+  },
+};
+let state: any;
+let execute: any;
+
+const QueryUser: FC<typeof props> = ({ query, variables }) => {
+  const [s, e] = useQuery({ query, variables });
+  state = s;
+  execute = e;
+  return <p>{s.data}</p>;
+};
+
+beforeAll(() => {
+  // tslint:disable-next-line
+  console.log(
+    'supressing console.error output due to react-test-renderer spam (hooks related)'
+  );
+  jest.spyOn(global.console, 'error').mockImplementation();
+});
+
+beforeEach(() => {
+  client.executeQuery.mockClear();
+  state = undefined;
+  execute = undefined;
+});
+
+describe('on initial useEffect', () => {
+  it('initialises default state', () => {
+    renderer.create(<QueryUser {...props} />);
+    expect(state).toMatchSnapshot();
+  });
+
+  it('executes subscription', () => {
+    renderer.create(<QueryUser {...props} />);
+    expect(client.executeQuery).toBeCalledTimes(1);
+  });
+
+  it('passes query and vars to executeQuery', () => {
+    renderer.create(<QueryUser {...props} />);
+    expect(client.executeQuery).toBeCalledWith(props);
+  });
+});
+
+describe('on subscription', () => {
+  it('sets fetching to true', () => {
+    const wrapper = renderer.create(<QueryUser {...props} />);
+    wrapper.update(<QueryUser {...props} />);
+    expect(state).toHaveProperty('fetching', true);
+  });
+});
+
+describe('on subscription update', () => {
+  it('forwards data response', done => {
+    const wrapper = renderer.create(<QueryUser {...props} />);
+    /**
+     * Have to call update (without changes) in order to see the
+     * result of the state change.
+     */
+    wrapper.update(<QueryUser {...props} />);
+
+    setTimeout(() => {
+      wrapper.update(<QueryUser {...props} />);
+      expect(state).toHaveProperty('data', 0);
+      done();
+    }, 400);
+  });
+
+  it('forwards error response', done => {
+    const wrapper = renderer.create(<QueryUser {...props} />);
+    /**
+     * Have to call update (without changes) in order to see the
+     * result of the state change.
+     */
+    wrapper.update(<QueryUser {...props} />);
+
+    setTimeout(() => {
+      wrapper.update(<QueryUser {...props} />);
+      expect(state).toHaveProperty('error', 1);
+      done();
+    }, 400);
+  });
+
+  it('sets fetching to false', done => {
+    const wrapper = renderer.create(<QueryUser {...props} />);
+    /**
+     * Have to call update (without changes) in order to see the
+     * result of the state change.
+     */
+    wrapper.update(<QueryUser {...props} />);
+
+    setTimeout(() => {
+      wrapper.update(<QueryUser {...props} />);
+      expect(state).toHaveProperty('fetching', false);
+      done();
+    }, 400);
+  });
+});
+
+describe('on change', () => {
+  const q = 'new query';
+
+  it('new query executes subscription', () => {
+    const wrapper = renderer.create(<QueryUser {...props} />);
+
+    /**
+     * Have to call update twice for the change to be detected.
+     * Only a single change is detected (updating 5 times still only calls
+     * execute subscription twice).
+     */
+    wrapper.update(<QueryUser {...props} query={q} />);
+    wrapper.update(<QueryUser {...props} query={q} />);
+    expect(client.executeQuery).toBeCalledTimes(2);
+  });
+});
+
+describe('execute query', () => {
+  it('triggers query execution', () => {
+    renderer.create(<QueryUser {...props} />);
+    act(() => execute());
+    expect(client.executeQuery).toBeCalledTimes(2);
+  });
+});
diff --git a/src/react/useSubscription.test.tsx b/src/react/useSubscription.test.tsx
new file mode 100644
index 00000000..a0f1d17c
--- /dev/null
+++ b/src/react/useSubscription.test.tsx
@@ -0,0 +1,81 @@
+// Note: Testing for hooks is not yet supported in Enzyme - https://github.com/airbnb/enzyme/issues/2011
+jest.mock('../lib/client', () => {
+  const d = { data: 1234, error: 5678 };
+  const { fromArray } = require('wonka');
+  const mock = {
+    executeSubscription: jest.fn(() => fromArray([d])),
+  };
+
+  return {
+    createClient: () => mock,
+    data: d,
+  };
+});
+
+import React, { FC } from 'react';
+import renderer from 'react-test-renderer';
+// @ts-ignore - data is imported from mock only
+import { createClient, data } from '../lib/client';
+import { useSubscription } from './useSubscription';
+
+// @ts-ignore
+const client = createClient() as { executeSubscription: jest.Mock };
+const query = `example query`;
+let state: any;
+
+const SubscriptionUser: FC<{ q: string }> = ({ q }) => {
+  const [s] = useSubscription({ query: q });
+  state = s;
+  return <p>{s.data}</p>;
+};
+
+beforeEach(() => {
+  client.executeSubscription.mockClear();
+  state = undefined;
+});
+
+describe('on initial useEffect', () => {
+  it('initialises default state', () => {
+    renderer.create(<SubscriptionUser q={query} />);
+    expect(state).toMatchSnapshot();
+  });
+
+  it('executes subscription', () => {
+    renderer.create(<SubscriptionUser q={query} />);
+    expect(client.executeSubscription).toBeCalledTimes(1);
+  });
+
+  it('passes query to executeSubscription', () => {
+    renderer.create(<SubscriptionUser q={query} />);
+    expect(client.executeSubscription).toBeCalledWith({ query, variables: {} });
+  });
+});
+
+describe('on subscription', () => {
+  it('forwards client response', () => {
+    const wrapper = renderer.create(<SubscriptionUser q={query} />);
+    /**
+     * Have to call update (without changes) in order to see the
+     * result of the state change.
+     */
+    wrapper.update(<SubscriptionUser q={query} />);
+    expect(state).toEqual(data);
+  });
+});
+
+describe('on change', () => {
+  const q = 'new query';
+
+  it('executes subscription', () => {
+    const wrapper = renderer.create(<SubscriptionUser q={query} />);
+
+    /**
+     * Have to call update twice for the change to be detected.
+     * Only a single change is detected (updating 5 times still only calls
+     * execute subscription twice).
+     */
+    wrapper.update(<SubscriptionUser q={q} />);
+    wrapper.update(<SubscriptionUser q={q} />);
+    expect(client.executeSubscription).toBeCalledTimes(2);
+  });
+});
diff --git a/src/test-utils/samples.ts b/src/test-utils/samples.ts
index b249ce0f..d16f9c91 100644
--- a/src/test-utils/samples.ts
+++ b/src/test-utils/samples.ts
@@ -1,10 +1,10 @@
 import {
-  OperationResult,
   ExecutionResult,
   GraphqlMutation,
   GraphqlQuery,
   GraphqlSubscription,
   Operation,
+  OperationResult,
 } from '../types';
 
 const context = {