typesafe-actions icon indicating copy to clipboard operation
typesafe-actions copied to clipboard

I would like an explanation of the Service type in epics.

Open rHermes opened this issue 6 years ago • 15 comments

Issuehunt badges

In the repo you write:

PS: If you're wondering what the Services type is in the epics signature and how to declare it in your application to easily inject statically typed API clients to your epics also ensuring for easy mocking while testing resulting in clean architecture, please create an issue for it and perhaps I'll find some time in the future to write an article about it.

I would like to read such an article :)


IssueHunt Summary

Sponsors (Total: $40.00)

Become a sponsor now!

Or submit a pull request to get the deposits!

Tips

rHermes avatar Jan 09 '19 14:01 rHermes

If you initialize your store like this :

const dependencies = {
	provider1 = new Provider1(),
	provider2 = new Provider2(),
	provider3 = new Provider3()
};

function configureStore() {
	const epicMiddleware = createEpicMiddleware<RootAction, RootAction, RootState>({
		dependencies
	});
	const middlewares = [epicMiddleware];
	const enhancer = composeWithDevTools(applyMiddleware(...middlewares));
	const store = createStore(rootReducer, enhancer);

	epicMiddleware.run(rootEpic);

	return store;
}

const store = configureStore();

export default store;

Then the type of Services would be :

export type RootServices = typeof dependencies;

Which is equivalent to :

export type RootServices = {
	provider1: Provider1,
	provider2: new Provider2,
	provider3: new Provider3
}

And then you woul write your epics like this :

export const exampleFlow: Epic<RootAction, RootAction, RootState, RootServices> =
(action$, state$, { provider1 }) =>
	action$.pipe(
		filter(isActionOf(exampleAsyncAction.request)),
		concatMap(action =>
			from(provider1 .exampleFunction()).pipe(
				map(exampleAsyncAction.success),
				catchError(() => of(exampleAsyncAction.failure()))
			)
		)
	);

One caveat of this approach is it make painfull to test your epics with Jest since you would have to inject all dependencies (or mock of them) in all your epics.

I personnaly prefer to avoid a global RootServices type and write more explicitly the exact providers I need for each Epic. For the above exemple it would give :

export const exampleFlow: Epic<RootAction, RootAction, RootState, { provider1: Provider1 }> =
(action$, state$, { provider1 }) =>
	action$.pipe(
		filter(isActionOf(exampleAsyncAction.request)),
		concatMap(action =>
			from(provider1 .exampleFunction()).pipe(
				map(exampleAsyncAction.success),
				catchError(() => of(exampleAsyncAction.failure()))
			)
		)
	);

It makes your epics it little bit more verbose but it much more easy to write tests.

TomDemulierChevret avatar Feb 20 '19 16:02 TomDemulierChevret

@TomDemulierChevret You mentioned a caveat with mocking of dependencies, so could you please explain how your second example is making it more easy to write tests?

Personally, I cannot see any benefits in your example except more work because of importing and declaring that extra explicit type annotations that you need in each epic.

piotrwitek avatar Feb 21 '19 17:02 piotrwitek

For exemple if I want to write tests for the epics of a particular subState that only requires 1 provider and not all of them.

jest.mock('../../providers/provider1');
const provider1: jest.Mocked<Provider1> = new Provider1() as any;

const providers = {
	provider1
};

let state$: StateObservable<RootState>;

describe('sub-state epics', () => {
	beforeEach(() => {
		state$ = new StateObservable<RootState>(new Subject<RootState>(), {
			subState: initialSubState
		} as RootState);
	});
	describe('firstSubStateEpic', () => {
		beforeEach(() => {
			provider1.function1.mockClear();
		});
		test('test1', done => {
			provider1.function1.mockImplementation(() => Promise.resolve(data));
			const action$ = ActionsObservable.of(someAsyncAction.request(defaultData));
			firstSubStateEpic(action$, state$, providers).subscribe((outputAction: Action) => {
				expect(provider1.function1).toBeCalled();
				expect(outputAction).toEqual(someAsyncAction.success(data));
				done();
			});
		});
	});
});

This way when I add new providers or modify some (outside of provider1) my test will still have valid typing and will still run perfectly. If I pass RootServices to my epics I would have to mock in some way every provider (even just a dummy mock).

But again it's just a matter of personnal taste.

TomDemulierChevret avatar Feb 21 '19 18:02 TomDemulierChevret

This way when I add new providers or modify some (outside of provider1) my test will still have valid typing and will still run perfectly.

With RootServices it's no different, you always have all the typings, because it's implementation driven, when you implement a new service type will updated itself automatically.

If I pass RootServices to my epics I would have to mock in some way every provider (even just a dummy mock).

RootServices is just a type, it has no impact on runtime so you don't have to mock anything extra except what you're testing. It would work perfectly fine with your example mocking only one provider that is used.

piotrwitek avatar Feb 21 '19 18:02 piotrwitek

Perhaps your problem is related to RootServices declaration, which is done not in the ambient context but in runtime and that's why you are dragging runtime code with import statement requiring to mock every dependency.

piotrwitek avatar Feb 21 '19 18:02 piotrwitek

I'm not talking about runtime issue but about typings.

How would you declare RootServices ? Because when I tried this approach, my const providers had to have every provider declared (event with just dummy mocking) for the typings to work.

TomDemulierChevret avatar Feb 22 '19 09:02 TomDemulierChevret

I suggest a different approach from yours. I have just added the approach that I'm recommending in my react-redux-typescript-guide repo:

  • https://github.com/piotrwitek/react-redux-typescript-guide#testing-epics

Main point is that I'm using a simple typesafe providers mock object which I can easily reuse in every test, and also I don't need to mock anything else comparing to what you're doing with jest.mock('../../providers/provider1'); const provider1: jest.Mocked<Provider1> = new Provider1() as any; which is IMO unnecessary.

Compare it to this which can handle all tests you'll ever write:

// Simple typesafe mock of all the services, you dont't need to mock anything else
// It is decoupled and reusable for all your tests, just put it in a separate file
const services = {
  logger: {
    log: jest.fn<Services['logger']['log']>(),
  },
  localStorage: {
    loadState: jest.fn<Services['localStorage']['loadState']>(),
    saveState: jest.fn<Services['localStorage']['saveState']>(),
  },
};

piotrwitek avatar Feb 22 '19 11:02 piotrwitek

Interesting. I was using jest.mock because my providers are ES6 class but your approach would work too.

But wouldn't your approach force you to modify the const services everytime you modify yours providers (or add another one) ?

TomDemulierChevret avatar Feb 22 '19 12:02 TomDemulierChevret

Yes it would require to update the mock, but it's just a simple JS object encapsulated in one place, so it's really easy and fast to do. Moreover, you have a complete type-safety so you'll know when you need to do it and you'll use refactoring to update all of your tests at once because of shared mock.

In contrast, when automocking each provider separately with jest.mock it's hard to tell how that would work with type-safety and if refactoring would work across the board as nicely in all tests files.

piotrwitek avatar Feb 22 '19 12:02 piotrwitek

All valid points, I will try it.

TomDemulierChevret avatar Feb 22 '19 13:02 TomDemulierChevret

I've tried your way and it works pretty well.

One issue thought : if your providers are classes with private methods/members, mocking them the way you did won't be type compliant.

So you'll end having to do something like this (only public methods are mocked) :

const services = {
  logger: ({
    log: jest.fn<Services['logger']['log']>(),
  } as Partial<Logger>) as jest.Mocked<Logger>,
  localStorage: ({
    loadState: jest.fn<Services['localStorage']['loadState']>(),
    saveState: jest.fn<Services['localStorage']['saveState']>(),
  } as Partial<LocalStorage>) as jest.Mocked<LocalStorage>,
};

If you have a better way, I'll gladly hear it.

TomDemulierChevret avatar Feb 26 '19 11:02 TomDemulierChevret

I'm not using classes so I cannot tell, but please check these manual mocking approaches from the docs, I'm pretty sure one of them should be working good with classes: https://jestjs.io/docs/en/es6-class-mocks#manual-mock

piotrwitek avatar Feb 26 '19 15:02 piotrwitek

@issuehunt has funded $40.00 to this issue.


issuehunt-oss[bot] avatar Jun 18 '19 04:06 issuehunt-oss[bot]

Hi, @piotrwitek could you please add a fake async function which actually returns something to test in https://github.com/piotrwitek/react-redux-typescript-guide#testing-epics? There are some warnings emerged when I did that.

ithinco avatar Sep 06 '19 10:09 ithinco

@ithinco could you please provide more details about what you need exactly please? I don't want to guess.

piotrwitek avatar Sep 20 '19 21:09 piotrwitek