Repository: open-feature/js-sdk (ts)

Issue:
Add utility function to clear providers for testing purposes
### Requirements

When writing tests we can observe some weird behavior because there is no way to unlink providers from the `Openfeature` singleton.

It could be great to have a way to clear the providers from the singleton to be able to create new tests that are not dependent on the previous situation of the singleton.
I am thinking of something similar to [`clearHooks`](https://github.com/open-feature/js-sdk/blob/95332ab5b2cc523b0ec7104d7e72c8a693887209/packages/shared/src/open-feature.ts#L45).


Pull request:
feat: add support for clearing providers
## This PR

- adds support for clearing providers

### Related Issues

Fixes #526 

### Notes

We may want to consider adding a reset method that calls `clearHooks`, `clearProviders`, rests the logger, and clears event listeners. There are a few tests that use a hacky method to do this already.



Patch:
diff --git a/packages/client/src/open-feature.ts b/packages/client/src/open-feature.ts
index 90909e81..d9ebd726 100644
--- a/packages/client/src/open-feature.ts
+++ b/packages/client/src/open-feature.ts
@@ -75,6 +75,14 @@ export class OpenFeatureAPI extends OpenFeatureCommonAPI<Provider> implements Ma
       { name, version }
     );
   }
+
+  /**
+   * Clears all registered providers and resets the default provider.
+   * @returns {Promise<void>}
+   */
+  clearProviders(): Promise<void> {
+    return super.clearProvidersAndSetDefault(NOOP_PROVIDER);
+  }
 }
 
 /**
diff --git a/packages/server/src/open-feature.ts b/packages/server/src/open-feature.ts
index 5df264a7..fb6d88aa 100644
--- a/packages/server/src/open-feature.ts
+++ b/packages/server/src/open-feature.ts
@@ -19,7 +19,6 @@ const _globalThis = globalThis as OpenFeatureGlobal;
 export class OpenFeatureAPI extends OpenFeatureCommonAPI<Provider> implements ManageContext<OpenFeatureAPI> {
   protected _defaultProvider: Provider = NOOP_PROVIDER;
 
-  // eslint-disable-next-line @typescript-eslint/no-empty-function
   private constructor() {
     super('server');
   }
@@ -104,6 +103,14 @@ export class OpenFeatureAPI extends OpenFeatureCommonAPI<Provider> implements Ma
       context
     );
   }
+
+  /**
+   * Clears all registered providers and resets the default provider.
+   * @returns {Promise<void>}
+   */
+  clearProviders(): Promise<void> {
+    return super.clearProvidersAndSetDefault(NOOP_PROVIDER);
+  }
 }
 
 /**
diff --git a/packages/shared/src/open-feature.ts b/packages/shared/src/open-feature.ts
index 573ce40e..ca337321 100644
--- a/packages/shared/src/open-feature.ts
+++ b/packages/shared/src/open-feature.ts
@@ -329,12 +329,23 @@ export abstract class OpenFeatureCommonAPI<P extends CommonProvider = CommonProv
         try {
           await provider.onClose?.();
         } catch (err) {
-          this.handleShutdownError(this._defaultProvider, err);
+          this.handleShutdownError(provider, err);
         }
       })
     );
   }
 
+  protected async clearProvidersAndSetDefault(defaultProvider: P): Promise<void> {
+    try {
+      await this.close();
+    } catch (err) {
+      this._logger.error('Unable to cleanly close providers. Resetting to the default configuration.');
+    } finally {
+      this._clientProviders.clear();
+      this._defaultProvider = defaultProvider;
+    }
+  }
+
   private handleShutdownError(provider: P, err: unknown) {
     this._logger.error(`Error during shutdown of provider ${provider.metadata.name}: ${err}`);
     this._logger.error((err as Error)?.stack);


Test patch:
diff --git a/packages/client/test/client.spec.ts b/packages/client/test/client.spec.ts
index 109885c2..58088d03 100644
--- a/packages/client/test/client.spec.ts
+++ b/packages/client/test/client.spec.ts
@@ -96,7 +96,8 @@ describe('OpenFeatureClient', () => {
     OpenFeature.setProvider(MOCK_PROVIDER);
   });
 
-  afterEach(() => {
+  afterEach(async () => {
+    await OpenFeature.clearProviders();
     jest.clearAllMocks();
   });
 
@@ -367,164 +368,165 @@ describe('OpenFeatureClient', () => {
       });
     });
   });
-});
-
-describe('Requirement 1.4.3.1', () => {
-  describe('generic support', () => {
-    it('should support generics', () => {
-      // No generic information exists at runtime, but this test has some value in ensuring the generic args still exist in the typings.
-      const client = OpenFeature.getClient();
-      const details: ResolutionDetails<JsonValue> = client.getObjectDetails('flag', { key: 'value' });
-
-      expect(details).toBeDefined();
-    });
-  });
-});
-
-describe('Evaluation details structure', () => {
-  const flagKey = 'number-details';
-  const defaultValue = 1970;
-  let details: EvaluationDetails<number>;
-
-  describe('Normal execution', () => {
-    beforeAll(() => {
-      const client = OpenFeature.getClient();
-      details = client.getNumberDetails(flagKey, defaultValue);
-
-      expect(details).toBeDefined();
-    });
-
-    describe('Requirement 1.4.2, 1.4.3', () => {
-      it('should contain flag value', () => {
-        expect(details.value).toEqual(NUMBER_VALUE);
-      });
-    });
-
-    describe('Requirement 1.4.4', () => {
-      it('should contain flag key', () => {
-        expect(details.flagKey).toEqual(flagKey);
-      });
-    });
-
-    describe('Requirement 1.4.5', () => {
-      it('should contain flag variant', () => {
-        expect(details.variant).toEqual(NUMBER_VARIANT);
-      });
-    });
 
-    describe('Requirement 1.4.6', () => {
-      it('should contain reason', () => {
-        expect(details.reason).toEqual(REASON);
+  describe('Requirement 1.4.3.1', () => {
+    describe('generic support', () => {
+      it('should support generics', () => {
+        // No generic information exists at runtime, but this test has some value in ensuring the generic args still exist in the typings.
+        const client = OpenFeature.getClient();
+        const details: ResolutionDetails<JsonValue> = client.getObjectDetails('flag', { key: 'value' });
+  
+        expect(details).toBeDefined();
       });
     });
   });
-
-  describe('Abnormal execution', () => {
-    const NON_OPEN_FEATURE_ERROR_MESSAGE = 'A null dereference or something, I dunno.';
-    const OPEN_FEATURE_ERROR_MESSAGE = "This ain't the flag you're looking for.";
-    let nonOpenFeatureErrorDetails: EvaluationDetails<number>;
-    let openFeatureErrorDetails: EvaluationDetails<string>;
-    let client: Client;
-    const errorProvider = {
-      metadata: {
-        name: 'error-mock',
-      },
-      resolveNumberEvaluation: jest.fn((): Promise<ResolutionDetails<number>> => {
-        throw new Error(NON_OPEN_FEATURE_ERROR_MESSAGE); // throw a non-open-feature error
-      }),
-      resolveStringEvaluation: jest.fn((): Promise<ResolutionDetails<string>> => {
-        throw new FlagNotFoundError(OPEN_FEATURE_ERROR_MESSAGE); // throw an open-feature error
-      }),
-    } as unknown as Provider;
-    const defaultNumberValue = 123;
-    const defaultStringValue = 'hey!';
-
-    beforeAll(() => {
-      OpenFeature.setProvider(errorProvider);
-      client = OpenFeature.getClient();
-      nonOpenFeatureErrorDetails = client.getNumberDetails('some-flag', defaultNumberValue);
-      openFeatureErrorDetails = client.getStringDetails('some-flag', defaultStringValue);
-    });
-
-    describe('Requirement 1.4.7', () => {
-      describe('OpenFeatureError', () => {
-        it('should contain error code', () => {
-          expect(openFeatureErrorDetails.errorCode).toBeTruthy();
-          expect(openFeatureErrorDetails.errorCode).toEqual(ErrorCode.FLAG_NOT_FOUND); // should get code from thrown OpenFeatureError
-        });
+  
+  describe('Evaluation details structure', () => {
+    const flagKey = 'number-details';
+    const defaultValue = 1970;
+    let details: EvaluationDetails<number>;
+  
+    describe('Normal execution', () => {
+      beforeEach(() => {
+        const client = OpenFeature.getClient();
+        details = client.getNumberDetails(flagKey, defaultValue);
+  
+        expect(details).toBeDefined();
       });
-
-      describe('Non-OpenFeatureError', () => {
-        it('should contain error code', () => {
-          expect(nonOpenFeatureErrorDetails.errorCode).toBeTruthy();
-          expect(nonOpenFeatureErrorDetails.errorCode).toEqual(ErrorCode.GENERAL); // should fall back to GENERAL
+  
+      describe('Requirement 1.4.2, 1.4.3', () => {
+        it('should contain flag value', () => {
+          expect(details.value).toEqual(NUMBER_VALUE);
         });
       });
-    });
-
-    describe('Requirement 1.4.8', () => {
-      it('should contain error reason', () => {
-        expect(nonOpenFeatureErrorDetails.reason).toEqual(StandardResolutionReasons.ERROR);
-        expect(openFeatureErrorDetails.reason).toEqual(StandardResolutionReasons.ERROR);
-      });
-    });
-
-    describe('Requirement 1.4.9', () => {
-      it('must not throw, must return default', () => {
-        nonOpenFeatureErrorDetails = client.getNumberDetails('some-flag', defaultNumberValue);
-
-        expect(nonOpenFeatureErrorDetails).toBeTruthy();
-        expect(nonOpenFeatureErrorDetails.value).toEqual(defaultNumberValue);
+  
+      describe('Requirement 1.4.4', () => {
+        it('should contain flag key', () => {
+          expect(details.flagKey).toEqual(flagKey);
+        });
       });
-    });
-
-    describe('Requirement 1.4.12', () => {
-      describe('OpenFeatureError', () => {
-        it('should contain "error" message', () => {
-          expect(openFeatureErrorDetails.errorMessage).toEqual(OPEN_FEATURE_ERROR_MESSAGE);
+  
+      describe('Requirement 1.4.5', () => {
+        it('should contain flag variant', () => {
+          expect(details.variant).toEqual(NUMBER_VARIANT);
         });
       });
-
-      describe('Non-OpenFeatureError', () => {
-        it('should contain "error" message', () => {
-          expect(nonOpenFeatureErrorDetails.errorMessage).toEqual(NON_OPEN_FEATURE_ERROR_MESSAGE);
+  
+      describe('Requirement 1.4.6', () => {
+        it('should contain reason', () => {
+          expect(details.reason).toEqual(REASON);
         });
       });
     });
-  });
-
-  describe('Requirement 1.4.13, Requirement 1.4.14', () => {
-    it('should return immutable `flag metadata` as defined by the provider', () => {
-      const flagMetadata = {
-        url: 'https://test.dev',
-        version: '1',
-      };
-
-      const flagMetadataProvider = {
+  
+    describe('Abnormal execution', () => {
+      const NON_OPEN_FEATURE_ERROR_MESSAGE = 'A null dereference or something, I dunno.';
+      const OPEN_FEATURE_ERROR_MESSAGE = "This ain't the flag you're looking for.";
+      let nonOpenFeatureErrorDetails: EvaluationDetails<number>;
+      let openFeatureErrorDetails: EvaluationDetails<string>;
+      let client: Client;
+      const errorProvider = {
         metadata: {
-          name: 'flag-metadata',
+          name: 'error-mock',
         },
-        resolveBooleanEvaluation: jest.fn((): ResolutionDetails<boolean> => {
-          return {
-            value: true,
-            flagMetadata,
-          };
+        resolveNumberEvaluation: jest.fn((): Promise<ResolutionDetails<number>> => {
+          throw new Error(NON_OPEN_FEATURE_ERROR_MESSAGE); // throw a non-open-feature error
+        }),
+        resolveStringEvaluation: jest.fn((): Promise<ResolutionDetails<string>> => {
+          throw new FlagNotFoundError(OPEN_FEATURE_ERROR_MESSAGE); // throw an open-feature error
         }),
       } as unknown as Provider;
-
-      OpenFeature.setProvider(flagMetadataProvider);
-      const client = OpenFeature.getClient();
-      const response = client.getBooleanDetails('some-flag', false);
-      expect(response.flagMetadata).toBe(flagMetadata);
-      expect(Object.isFrozen(response.flagMetadata)).toBeTruthy();
+      const defaultNumberValue = 123;
+      const defaultStringValue = 'hey!';
+  
+      beforeEach(() => {
+        OpenFeature.setProvider(errorProvider);
+        client = OpenFeature.getClient();
+        nonOpenFeatureErrorDetails = client.getNumberDetails('some-flag', defaultNumberValue);
+        openFeatureErrorDetails = client.getStringDetails('some-flag', defaultStringValue);
+      });
+  
+      describe('Requirement 1.4.7', () => {
+        describe('OpenFeatureError', () => {
+          it('should contain error code', () => {
+            expect(openFeatureErrorDetails.errorCode).toBeTruthy();
+            expect(openFeatureErrorDetails.errorCode).toEqual(ErrorCode.FLAG_NOT_FOUND); // should get code from thrown OpenFeatureError
+          });
+        });
+  
+        describe('Non-OpenFeatureError', () => {
+          it('should contain error code', () => {
+            expect(nonOpenFeatureErrorDetails.errorCode).toBeTruthy();
+            expect(nonOpenFeatureErrorDetails.errorCode).toEqual(ErrorCode.GENERAL); // should fall back to GENERAL
+          });
+        });
+      });
+  
+      describe('Requirement 1.4.8', () => {
+        it('should contain error reason', () => {
+          expect(nonOpenFeatureErrorDetails.reason).toEqual(StandardResolutionReasons.ERROR);
+          expect(openFeatureErrorDetails.reason).toEqual(StandardResolutionReasons.ERROR);
+        });
+      });
+  
+      describe('Requirement 1.4.9', () => {
+        it('must not throw, must return default', () => {
+          nonOpenFeatureErrorDetails = client.getNumberDetails('some-flag', defaultNumberValue);
+  
+          expect(nonOpenFeatureErrorDetails).toBeTruthy();
+          expect(nonOpenFeatureErrorDetails.value).toEqual(defaultNumberValue);
+        });
+      });
+  
+      describe('Requirement 1.4.12', () => {
+        describe('OpenFeatureError', () => {
+          it('should contain "error" message', () => {
+            expect(openFeatureErrorDetails.errorMessage).toEqual(OPEN_FEATURE_ERROR_MESSAGE);
+          });
+        });
+  
+        describe('Non-OpenFeatureError', () => {
+          it('should contain "error" message', () => {
+            expect(nonOpenFeatureErrorDetails.errorMessage).toEqual(NON_OPEN_FEATURE_ERROR_MESSAGE);
+          });
+        });
+      });
     });
-
-    it('should return empty `flag metadata` because it was not set by the provider', () => {
-      // The mock provider doesn't contain flag metadata
-      OpenFeature.setProvider(MOCK_PROVIDER);
-      const client = OpenFeature.getClient();
-      const response = client.getBooleanDetails('some-flag', false);
-      expect(response.flagMetadata).toEqual({});
+  
+    describe('Requirement 1.4.13, Requirement 1.4.14', () => {
+      it('should return immutable `flag metadata` as defined by the provider', () => {
+        const flagMetadata = {
+          url: 'https://test.dev',
+          version: '1',
+        };
+  
+        const flagMetadataProvider = {
+          metadata: {
+            name: 'flag-metadata',
+          },
+          resolveBooleanEvaluation: jest.fn((): ResolutionDetails<boolean> => {
+            return {
+              value: true,
+              flagMetadata,
+            };
+          }),
+        } as unknown as Provider;
+  
+        OpenFeature.setProvider(flagMetadataProvider);
+        const client = OpenFeature.getClient();
+        const response = client.getBooleanDetails('some-flag', false);
+        expect(response.flagMetadata).toBe(flagMetadata);
+        expect(Object.isFrozen(response.flagMetadata)).toBeTruthy();
+      });
+  
+      it('should return empty `flag metadata` because it was not set by the provider', () => {
+        // The mock provider doesn't contain flag metadata
+        OpenFeature.setProvider(MOCK_PROVIDER);
+        const client = OpenFeature.getClient();
+        const response = client.getBooleanDetails('some-flag', false);
+        expect(response.flagMetadata).toEqual({});
+      });
     });
   });
 });
+
diff --git a/packages/client/test/events.spec.ts b/packages/client/test/events.spec.ts
index 5715b30a..e445cae0 100644
--- a/packages/client/test/events.spec.ts
+++ b/packages/client/test/events.spec.ts
@@ -79,7 +79,8 @@ describe('Events', () => {
   jest.setTimeout(TIMEOUT);
   let clientId = uuid();
 
-  afterEach(() => {
+  afterEach(async () => {
+    await OpenFeature.clearProviders();
     jest.clearAllMocks();
     clientId = uuid();
     // hacky, but it's helpful to clear the handlers between tests
diff --git a/packages/client/test/hooks.spec.ts b/packages/client/test/hooks.spec.ts
index e525a024..7f94f85c 100644
--- a/packages/client/test/hooks.spec.ts
+++ b/packages/client/test/hooks.spec.ts
@@ -45,15 +45,13 @@ describe('Hooks', () => {
   let client: Client;
   const FLAG_KEY = 'my-flag';
 
-  afterEach(() => {
+  afterEach(async () => {
+    await OpenFeature.clearProviders();
     jest.clearAllMocks();
   });
 
-  beforeAll(() => {
+  beforeEach(async () => {
     OpenFeature.setProvider(MOCK_PROVIDER);
-  });
-
-  beforeEach(() => {
     client = OpenFeature.getClient();
   });
 
@@ -226,7 +224,7 @@ describe('Hooks', () => {
   });
 
   describe('"error" stage', () => {
-    beforeAll(() => {
+    beforeEach(() => {
       OpenFeature.setProvider(MOCK_ERROR_PROVIDER);
     });
 
diff --git a/packages/client/test/open-feature.spec.ts b/packages/client/test/open-feature.spec.ts
index 1dbe4420..0cced369 100644
--- a/packages/client/test/open-feature.spec.ts
+++ b/packages/client/test/open-feature.spec.ts
@@ -18,7 +18,8 @@ const mockProvider = (config?: { initialStatus?: ProviderStatus; runsOn?: Paradi
 };
 
 describe('OpenFeature', () => {
-  afterEach(() => {
+  afterEach(async () => {
+    await OpenFeature.clearProviders();
     jest.clearAllMocks();
   });
 
diff --git a/packages/server/test/client.spec.ts b/packages/server/test/client.spec.ts
index be0b0ee6..19c0829b 100644
--- a/packages/server/test/client.spec.ts
+++ b/packages/server/test/client.spec.ts
@@ -87,7 +87,8 @@ describe('OpenFeatureClient', () => {
     OpenFeature.setProvider(MOCK_PROVIDER);
   });
 
-  afterEach(() => {
+  afterEach(async () => {
+    await OpenFeature.clearProviders();
     jest.clearAllMocks();
   });
 
@@ -366,7 +367,7 @@ describe('OpenFeatureClient', () => {
     let details: EvaluationDetails<number>;
 
     describe('Normal execution', () => {
-      beforeAll(async () => {
+      beforeEach(async () => {
         const client = OpenFeature.getClient();
         details = await client.getNumberDetails(flagKey, defaultValue);
 
diff --git a/packages/server/test/events.spec.ts b/packages/server/test/events.spec.ts
index 37e0ba3b..55f076d0 100644
--- a/packages/server/test/events.spec.ts
+++ b/packages/server/test/events.spec.ts
@@ -82,7 +82,8 @@ describe('Events', () => {
   jest.setTimeout(TIMEOUT);
   let clientId = uuid();
 
-  afterEach(() => {
+  afterEach(async () => {
+    await OpenFeature.clearProviders();
     jest.clearAllMocks();
     clientId = uuid();
     // hacky, but it's helpful to clear the handlers between tests
diff --git a/packages/server/test/hooks.spec.ts b/packages/server/test/hooks.spec.ts
index cef0e9f3..849db312 100644
--- a/packages/server/test/hooks.spec.ts
+++ b/packages/server/test/hooks.spec.ts
@@ -41,15 +41,13 @@ describe('Hooks', () => {
   let client: Client;
   const FLAG_KEY = 'my-flag';
 
-  afterEach(() => {
+  afterEach(async () => {
+    await OpenFeature.clearProviders();
     jest.clearAllMocks();
   });
 
-  beforeAll(() => {
-    OpenFeature.setProvider(MOCK_PROVIDER);
-  });
-
   beforeEach(() => {
+    OpenFeature.setProvider(MOCK_PROVIDER);
     client = OpenFeature.getClient();
   });
 
@@ -280,7 +278,7 @@ describe('Hooks', () => {
   });
 
   describe('"error" stage', () => {
-    beforeAll(() => {
+    beforeEach(() => {
       OpenFeature.setProvider(MOCK_ERROR_PROVIDER);
     });
 
diff --git a/packages/server/test/logger.spec.ts b/packages/server/test/logger.spec.ts
index bcb9844a..08a539a5 100644
--- a/packages/server/test/logger.spec.ts
+++ b/packages/server/test/logger.spec.ts
@@ -45,11 +45,12 @@ const MOCK_PROVIDER: Provider = {
 };
 
 describe('Logger', () => {
-  beforeAll(() => {
+  beforeEach(() => {
     OpenFeature.setProvider(MOCK_PROVIDER);
   });
 
-  afterEach(() => {
+  afterEach(async () => {
+    await OpenFeature.clearProviders();
     jest.clearAllMocks();
     // Resetting the default logger on the singleton
     OpenFeature['_logger'] = new DefaultLogger();
diff --git a/packages/server/test/open-feature.spec.ts b/packages/server/test/open-feature.spec.ts
index 83097857..a8422962 100644
--- a/packages/server/test/open-feature.spec.ts
+++ b/packages/server/test/open-feature.spec.ts
@@ -18,7 +18,8 @@ const mockProvider = (config?: { initialStatus?: ProviderStatus; runsOn?: Paradi
 };
 
 describe('OpenFeature', () => {
-  afterEach(() => {
+  afterEach(async () => {
+    await OpenFeature.clearProviders();
     jest.clearAllMocks();
   });