diff --git src/wp-admin/includes/ajax-actions.php src/wp-admin/includes/ajax-actions.php
index f3c5428..05c49af 100644
--- src/wp-admin/includes/ajax-actions.php
+++ src/wp-admin/includes/ajax-actions.php
@@ -4429,7 +4429,7 @@ function wp_ajax_wp_privacy_export_personal_data() {
 		}
 
 		if ( $exporter_index > count( $exporters ) ) {
-			wp_send_json_error( __( 'Exporter index out of range.' ) );
+			wp_send_json_error( __( 'Exporter index is out of range.' ) );
 		}
 
 		if ( $page < 1 ) {
@@ -4452,23 +4452,25 @@ function wp_ajax_wp_privacy_export_personal_data() {
 				sprintf( __( 'Exporter array at index %s does not include a friendly name.' ), $exporter_key )
 			);
 		}
+
+		$exporter_friendly_name = $exporter['exporter_friendly_name'];
+
 		if ( ! array_key_exists( 'callback', $exporter ) ) {
 			wp_send_json_error(
 				/* translators: %s: exporter friendly name */
-				sprintf( __( 'Exporter does not include a callback: %s.' ), esc_html( $exporter['exporter_friendly_name'] ) )
+				sprintf( __( 'Exporter does not include a callback: %s.' ), esc_html( $exporter_friendly_name ) )
 			);
 		}
 		if ( ! is_callable( $exporter['callback'] ) ) {
 			wp_send_json_error(
 				/* translators: %s: exporter friendly name */
-				sprintf( __( 'Exporter callback is not a valid callback: %s.' ), esc_html( $exporter['exporter_friendly_name'] ) )
+				sprintf( __( 'Exporter callback is not a valid callback: %s.' ), esc_html( $exporter_friendly_name ) )
 			);
 		}
 
-		$callback               = $exporter['callback'];
-		$exporter_friendly_name = $exporter['exporter_friendly_name'];
-
+		$callback = $exporter['callback'];
 		$response = call_user_func( $callback, $email_address, $page );
+
 		if ( is_wp_error( $response ) ) {
 			wp_send_json_error( $response );
 		}
@@ -4559,7 +4561,7 @@ function wp_ajax_wp_privacy_erase_personal_data() {
 	$request = wp_get_user_request_data( $request_id );
 
 	if ( ! $request || 'remove_personal_data' !== $request->action_name ) {
-		wp_send_json_error( __( 'Invalid request ID.' ) );
+		wp_send_json_error( __( 'Invalid request type.' ) );
 	}
 
 	$email_address = $request->email;
@@ -4626,24 +4628,34 @@ function wp_ajax_wp_privacy_erase_personal_data() {
 			wp_send_json_error( sprintf( __( 'Expected an array describing the eraser at index %d.' ), $eraser_index ) );
 		}
 
-		if ( ! array_key_exists( 'callback', $eraser ) ) {
-			/* translators: %d: array index */
-			wp_send_json_error( sprintf( __( 'Eraser array at index %d does not include a callback.' ), $eraser_index ) );
-		}
-
-		if ( ! is_callable( $eraser['callback'] ) ) {
-			/* translators: %d: array index */
-			wp_send_json_error( sprintf( __( 'Eraser callback at index %d is not a valid callback.' ), $eraser_index ) );
-		}
-
 		if ( ! array_key_exists( 'eraser_friendly_name', $eraser ) ) {
 			/* translators: %d: array index */
 			wp_send_json_error( sprintf( __( 'Eraser array at index %d does not include a friendly name.' ), $eraser_index ) );
 		}
 
-		$callback             = $eraser['callback'];
 		$eraser_friendly_name = $eraser['eraser_friendly_name'];
 
+		if ( ! array_key_exists( 'callback', $eraser ) ) {
+			wp_send_json_error(
+				sprintf(
+					/* translators: %d: array index */
+					__( 'Eraser does not include a callback: %s.' ),
+					esc_html( $eraser_friendly_name )
+				)
+			);
+		}
+
+		if ( ! is_callable( $eraser['callback'] ) ) {
+			wp_send_json_error(
+				sprintf(
+					/* translators: %d: array index */
+					__( 'Eraser callback is not valid: %s.' ),
+					esc_html( $eraser_friendly_name )
+				)
+			);
+		}
+
+		$callback = $eraser['callback'];
 		$response = call_user_func( $callback, $email_address, $page );
 
 		if ( is_wp_error( $response ) ) {
diff --git tests/phpunit/includes/testcase-ajax.php tests/phpunit/includes/testcase-ajax.php
index bf5a5bc..736d362 100644
--- tests/phpunit/includes/testcase-ajax.php
+++ tests/phpunit/includes/testcase-ajax.php
@@ -119,6 +119,8 @@ abstract class WP_Ajax_UnitTestCase extends WP_UnitTestCase {
 		'delete-theme',
 		'install-theme',
 		'get-post-thumbnail-html',
+		'wp-privacy-export-personal-data',
+		'wp-privacy-erase-personal-data',
 	);
 
 	public static function setUpBeforeClass() {
diff --git tests/phpunit/tests/ajax/PrivacyErasePersonalData.php tests/phpunit/tests/ajax/PrivacyErasePersonalData.php
new file mode 100644
index 0000000..3533fc2
--- /dev/null
+++ tests/phpunit/tests/ajax/PrivacyErasePersonalData.php
@@ -0,0 +1,795 @@
+<?php
+/**
+ * Testing Ajax handler for erasing personal data.
+ *
+ * @package WordPress\UnitTests
+ *
+ * @since 4.9.9
+ */
+
+/**
+ * Tests_Ajax_PrivacyExportPersonalData class.
+ *
+ * @since 4.9.9
+ *
+ * @group ajax
+ * @group privacy
+ * @covers wp_ajax_wp_privacy_erase_personal_data
+ */
+class Tests_Ajax_PrivacyErasePersonalData extends WP_Ajax_UnitTestCase {
+	/**
+	 * User Request ID.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @var int $request_id
+	 */
+	protected static $request_id;
+
+	/**
+	 * User Request Email.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @var string $request_email
+	 */
+	protected static $request_email;
+
+	/**
+	 * Ajax Action.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @var string $action
+	 */
+	protected static $action;
+
+	/**
+	 * Eraser Index.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @var int $eraser
+	 */
+	protected static $eraser;
+
+	/**
+	 * Eraser Key.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @var string $eraser_key
+	 */
+	protected static $eraser_key;
+
+	/**
+	 * Eraser Friendly Name.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @var string $eraser_friendly_name
+	 */
+	protected static $eraser_friendly_name;
+
+	/**
+	 * Page Index.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @var int $page
+	 */
+	protected static $page;
+
+	/**
+	 * Last response parsed.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @var array $_last_response_parsed
+	 */
+	protected $_last_response_parsed;
+
+	/**
+	 * A response index to unset.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @var string $key_to_unset
+	 */
+	protected $key_to_unset;
+
+	/**
+	 * A value to change the test exporter callback to.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @var string $new_callback_value
+	 */
+	protected $new_callback_value;
+
+	/**
+	 * Create user erase request fixtures.
+	 *
+	 * @param WP_UnitTest_Factory $factory Factory.
+	 */
+	public static function wpSetUpBeforeClass( $factory ) {
+		self::$request_email        = 'requester@example.com';
+		self::$request_id           = wp_create_user_request( self::$request_email, 'remove_personal_data' );
+		self::$action               = 'wp-privacy-erase-personal-data';
+		self::$eraser               = 1;
+		self::$eraser_key           = 'custom-eraser';
+		self::$eraser_friendly_name = 'Custom Eraser';
+		self::$page                 = 1;
+	}
+
+	/**
+	 * Register a custom personal data eraser.
+	 */
+	public function setUp() {
+		parent::setUp();
+
+		$this->key_to_unset = '';
+
+		// Make sure the erasers response is not modified and avoid sending emails.
+		remove_all_filters( 'wp_privacy_personal_data_erasure_page' );
+		remove_all_actions( 'wp_privacy_personal_data_erased' );
+
+		// Only use our custom privacy personal data eraser.
+		remove_all_filters( 'wp_privacy_personal_data_erasers' );
+		add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'register_custom_personal_data_eraser' ) );
+
+		$this->_setRole( 'administrator' );
+	}
+
+	/**
+	 * Clean up after each test method.
+	 */
+	public function tearDown() {
+		remove_filter( 'wp_privacy_personal_data_erasers', array( $this, 'register_custom_personal_data_eraser' ) );
+		$this->new_callback_value = '';
+
+		parent::tearDown();
+	}
+
+	/**
+	 * Unsets an array index in the test eraser.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @param array $erasers Erasers.
+	 * @return array $erasers Erasers.
+	 */
+	public function filter_unset_eraser_index( $erasers ) {
+		if ( ! empty( $this->key_to_unset ) ) {
+			unset( $erasers[ self::$eraser_key ][ $this->key_to_unset ] );
+		}
+
+		return $erasers;
+	}
+
+	/**
+	 * Change the test eraser callback to a specified value.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @param array $erasers List of data erasers.
+	 * @return array $erasersList of data erasers.
+	 */
+	public function filter_eraser_callback_value( $erasers ) {
+		$erasers[ self::$eraser_key ]['callback'] = $this->new_callback_value;
+
+		return $erasers;
+	}
+
+	/**
+	 * The function should send an error when the request ID is missing.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @ticket 43438
+	 */
+	public function test_error_when_missing_request_id() {
+		$this->assertNotWPError( self::$request_id );
+
+		// Set up a request.
+		$this->_make_ajax_call(
+			array(
+				'id' => null, // Missing request ID.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Missing request ID.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the request ID is less than 1.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @ticket 43438
+	 */
+	public function test_error_when_request_id_invalid() {
+		$this->assertNotWPError( self::$request_id );
+
+		// Set up a request.
+		$this->_make_ajax_call(
+			array(
+				'id' => -1, // Invalid request ID.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Invalid request ID.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the current user is missing required capabilities.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @ticket 43438
+	 */
+	public function test_error_when_current_user_missing_required_capabilities() {
+		$this->_setRole( 'author' );
+
+		$this->assertFalse( current_user_can( 'erase_others_personal_data' ) );
+		$this->assertFalse( current_user_can( 'delete_users' ) );
+
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Invalid request.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the nonce does not validate.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_failure_with_invalid_nonce() {
+		$this->setExpectedException( 'WPAjaxDieStopException', '-1' );
+
+		$this->_make_ajax_call(
+			array(
+				'security' => 'invalid-nonce',
+			)
+		);
+	}
+
+	/**
+	 * The function should send an error when the request type is incorrect.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_incorrect_request_type() {
+		$request_id = wp_create_user_request(
+			'export-request@example.com',
+			'export_personal_data' // Incorrect request type, expects 'remove_personal_data'.
+		);
+
+		$this->_make_ajax_call(
+			array(
+				'security' => wp_create_nonce( 'wp-privacy-erase-personal-data-' . $request_id ),
+				'id'       => $request_id,
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Invalid request type.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the request email is invalid.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_invalid_email() {
+		wp_update_post(
+			array(
+				'ID'         => self::$request_id,
+				'post_title' => '', // Invalid requester's email address.
+			)
+		);
+
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Invalid email address in request.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the eraser index is missing.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_missing_eraser_index() {
+		$this->_make_ajax_call(
+			array(
+				'eraser' => null, // Missing eraser index.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Missing eraser index.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the page index is missing.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_missing_page_index() {
+		$this->_make_ajax_call(
+			array(
+				'page' => null, // Missing page index.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Missing page index.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the eraser index is negative.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_negative_eraser_index() {
+		$this->_make_ajax_call(
+			array(
+				'eraser' => -1, // Negative eraser index.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Eraser index cannot be less than one.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the eraser index is out of range.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_eraser_index_out_of_range() {
+		$this->_make_ajax_call(
+			array(
+				'eraser' => PHP_INT_MAX, // Out of range eraser index.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Eraser index is out of range.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the page index is less than one.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_page_index_less_than_one() {
+		$this->_make_ajax_call(
+			array(
+				'page' => 0, // Page index less than one.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Page index cannot be less than one.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when an eraser is not an array.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_eraser_not_array() {
+		add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'filter_eraser_not_array' ), 20 );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Expected an array describing the eraser at index %s.',
+				self::$eraser
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * Change the eraser information to an invalid value.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @param array $erasers Erasers.
+	 * @return array $erasers Erasers.
+	 */
+	public function filter_eraser_not_array( $erasers ) {
+		$erasers[ self::$eraser_key ] = false;
+
+		return $erasers;
+	}
+
+	/**
+	 * The function should send an error when an eraser is missing a friendly name.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_eraser_missing_friendly_name() {
+		$this->key_to_unset = 'eraser_friendly_name';
+
+		add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'filter_unset_eraser_index' ), 20 );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Eraser array at index %s does not include a friendly name.',
+				self::$eraser
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * The function should send an error when an eraser is missing a callback.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_eraser_missing_callback() {
+		$this->key_to_unset = 'callback';
+
+		add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'filter_unset_eraser_index' ), 20 );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Eraser does not include a callback: %s.',
+				self::$eraser_friendly_name
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * The function should send an error when an eraser, at a given index, has an invalid callback.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_eraser_index_invalid_callback() {
+		$this->new_callback_value = false;
+
+		add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'filter_eraser_callback_value' ), 20 );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Eraser callback is not valid: %s.',
+				self::$eraser_friendly_name
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * The function should send an error when an eraser, at a given index, is missing an array response.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_eraser_index_invalid_response() {
+		$this->new_callback_value = '__return_null';
+
+		add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'filter_eraser_callback_value' ), 20 );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Did not receive array from %1$s eraser (index %2$d).',
+				self::$eraser_friendly_name,
+				self::$eraser
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * Change the test eraser's callback to one that unsets an index in the response.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @param array $erasers Erasers.
+	 * @return array $erasers Erasers.
+	 */
+	public function filter_callback_unset_index( $erasers ) {
+		$erasers[ self::$eraser_key ]['callback'] = array( $this, 'filter_unset_response_index' );
+
+		return $erasers;
+	}
+
+	/**
+	 * Unsets an array index in a response.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @param string $email_address The requester's email address.
+	 * @param int    $page          Page number.
+	 * @return array $return Export data.
+	 */
+	public function filter_unset_response_index( $email_address, $page = 1 ) {
+		$response = $this->callback_personal_data_eraser( $email_address, $page );
+
+		if ( ! empty( $this->key_to_unset ) ) {
+			unset( $response[ $this->key_to_unset ] );
+		}
+
+		return $response;
+	}
+
+	/**
+	 * The function should send an error when missing an items_removed index.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_eraser_items_removed_missing() {
+		$this->key_to_unset = 'items_removed';
+
+		add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'filter_callback_unset_index' ), 20 );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Expected items_removed key in response array from %1$s eraser (index %2$d).',
+				self::$eraser_friendly_name,
+				self::$eraser
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * The function should send an error when missing an items_retained index.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_eraser_items_retained_missing() {
+		$this->key_to_unset = 'items_retained';
+
+		add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'filter_callback_unset_index' ), 20 );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Expected items_retained key in response array from %1$s eraser (index %2$d).',
+				self::$eraser_friendly_name,
+				self::$eraser
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * The function should send an error when missing a messages index.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_eraser_messages_missing() {
+		$this->key_to_unset = 'messages';
+
+		add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'filter_callback_unset_index' ), 20 );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Expected messages key in response array from %1$s eraser (index %2$d).',
+				self::$eraser_friendly_name,
+				self::$eraser
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * The function should send an error when the messages index is not an array.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_eraser_messages_not_array() {
+		$this->new_callback_value = array( $this, 'filter_response_messages_invalid' );
+
+		add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'filter_eraser_callback_value' ), 20 );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Expected messages key to reference an array in response array from %1$s eraser (index %2$d).',
+				self::$eraser_friendly_name,
+				self::$eraser
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * Change the messages index to an invalid value (not an array).
+	 *
+	 * @since 4.9.9
+	 *
+	 * @param string $email_address The requester's email address.
+	 * @param int    $page          Page number.
+	 * @return array $return Export data.
+	 */
+	public function filter_response_messages_invalid( $email_address, $page = 1 ) {
+		$response             = $this->callback_personal_data_eraser( $email_address, $page );
+		$response['messages'] = true;
+
+		return $response;
+	}
+
+	/**
+	 * The function should send an error when an eraser is missing 'done' in array response.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_eraser_missing_done_response() {
+		$this->key_to_unset = 'done';
+
+		add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'filter_callback_unset_index' ), 20 );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Expected done flag in response array from %1$s eraser (index %2$d).',
+				self::$eraser_friendly_name,
+				self::$eraser
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * The function should successfully send erasers response data when the current user has the required
+	 * capabilities.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @ticket 43438
+	 */
+	public function test_success_when_current_user_has_required_capabilities() {
+		$this->assertTrue( current_user_can( 'erase_others_personal_data' ) );
+		$this->assertTrue( current_user_can( 'delete_users' ) );
+
+		$this->_make_ajax_call();
+
+		$this->assertSame( 'A message regarding retained data for requester@example.com.', $this->_last_response_parsed['data']['messages'][0] );
+		$this->assertTrue( $this->_last_response_parsed['success'] );
+		$this->assertTrue( $this->_last_response_parsed['data']['items_removed'] );
+		$this->assertTrue( $this->_last_response_parsed['data']['items_retained'] );
+		$this->assertTrue( $this->_last_response_parsed['data']['done'] );
+	}
+
+	/**
+	 * Test that the function's output should be filterable with the `wp_privacy_personal_data_erasure_page` filter.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_output_should_be_filterable() {
+		add_filter( 'wp_privacy_personal_data_erasure_page', array( $this, 'filter_exporter_data_response' ), 20, 6 );
+		$this->_make_ajax_call();
+
+		$expected_new_index = self::$request_email . '-' . self::$request_id . '-' . self::$eraser_key;
+
+		$this->assertTrue( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'filtered removed', $this->_last_response_parsed['data']['items_removed'] );
+		$this->assertSame( 'filtered retained', $this->_last_response_parsed['data']['items_retained'] );
+		$this->assertSame( array( 'filtered messages' ), $this->_last_response_parsed['data']['messages'] );
+		$this->assertSame( 'filtered done', $this->_last_response_parsed['data']['done'] );
+		$this->assertSame( $expected_new_index, $this->_last_response_parsed['data']['new_index'] );
+	}
+
+	/**
+	 * Filters the exporter response.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @param array  $response        The personal data for the given exporter and page.
+	 * @param int    $eraser_index    The index of the eraser that provided this data.
+	 * @param string $email_address   The email address associated with this personal data.
+	 * @param int    $page            The page for this response.
+	 * @param int    $request_id      The privacy request post ID associated with this request.
+	 * @param string $eraser_key      The key (slug) of the eraser that provided this data.
+	 * @return array Filtered export response.
+	 */
+	public function filter_exporter_data_response( $response, $eraser_index, $email_address, $page, $request_id, $eraser_key ) {
+		$response['items_removed']  = 'filtered removed';
+		$response['items_retained'] = 'filtered retained';
+		$response['messages']       = array( 'filtered messages' );
+		$response['done']           = 'filtered done';
+		$response['new_index']      = $email_address . '-' . $request_id . '-' . $eraser_key;
+
+		return $response;
+	}
+
+	/**
+	 * Register handler for a custom personal data eraser.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @param array $erasers An array of personal data erasers.
+	 * @return array $erasers An array of personal data erasers.
+	 */
+	public function register_custom_personal_data_eraser( $erasers ) {
+		$erasers[ self::$eraser_key ] = array(
+			'eraser_friendly_name' => self::$eraser_friendly_name,
+			'callback'             => array( $this, 'callback_personal_data_eraser' ),
+		);
+		return $erasers;
+	}
+
+	/**
+	 * Custom Personal Data Eraser.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @param  string $email_address The comment author email address.
+	 * @param  int    $page          Page number.
+	 * @return array  $return        Erase data.
+	 */
+	public function callback_personal_data_eraser( $email_address, $page = 1 ) {
+		if ( 1 === $page ) {
+			return array(
+				'items_removed'  => true,
+				'items_retained' => true,
+				'messages'       => array( sprintf( 'A message regarding retained data for %s.', $email_address ) ),
+				'done'           => true,
+			);
+		}
+
+		return array(
+			'items_removed'  => false,
+			'items_retained' => false,
+			'messages'       => array(),
+			'done'           => true,
+		);
+	}
+
+	/**
+	 * Helper function for ajax handler.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @param array $args Ajax request arguments.
+	 */
+	protected function _make_ajax_call( $args = array() ) {
+		$this->_last_response_parsed = null;
+		$this->_last_response        = '';
+
+		$defaults = array(
+			'action'   => self::$action,
+			'security' => wp_create_nonce( self::$action . '-' . self::$request_id ),
+			'page'     => self::$page,
+			'id'       => self::$request_id,
+			'eraser'   => self::$eraser,
+		);
+
+		$_POST = wp_parse_args( $args, $defaults );
+
+		try {
+			$this->_handleAjax( self::$action );
+		} catch ( WPAjaxDieContinueException $e ) {
+			unset( $e );
+		}
+
+		if ( $this->_last_response ) {
+			$this->_last_response_parsed = json_decode( $this->_last_response, true );
+		}
+	}
+}
diff --git tests/phpunit/tests/ajax/PrivacyExportPersonalData.php tests/phpunit/tests/ajax/PrivacyExportPersonalData.php
new file mode 100644
index 0000000..98c63b2
--- /dev/null
+++ tests/phpunit/tests/ajax/PrivacyExportPersonalData.php
@@ -0,0 +1,805 @@
+<?php
+/**
+ * Testing Ajax handler for exporting personal data.
+ *
+ * @package WordPress\UnitTests
+ *
+ * @since 4.9.9
+ */
+
+/**
+ * Tests_Ajax_PrivacyExportPersonalData class.
+ *
+ * @since 4.9.9
+ *
+ * @group ajax1
+ * @group privacy
+ * @covers wp_ajax_wp_privacy_export_personal_data
+ */
+class Tests_Ajax_PrivacyExportPersonalData extends WP_Ajax_UnitTestCase {
+	/**
+	 * User Request ID.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @var int $request_id
+	 */
+	protected static $request_id;
+
+	/**
+	 * User Request Email.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @var string $request_email
+	 */
+	protected static $request_email;
+
+	/**
+	 * Ajax Action.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @var string $action
+	 */
+	protected static $action;
+
+	/**
+	 * Exporter Index.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @var int $exporter
+	 */
+	protected static $exporter;
+
+	/**
+	 * Exporter Key.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @var string $exporter_key
+	 */
+	protected static $exporter_key;
+
+	/**
+	 * Exporter Friendly Name.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @var string $exporter_friendly_name
+	 */
+	protected static $exporter_friendly_name;
+
+	/**
+	 * Page Index.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @var int $page
+	 */
+	protected static $page;
+
+	/**
+	 * Send As Email.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @var bool $send_as_email
+	 */
+	protected static $send_as_email;
+
+	/**
+	 * Last response parsed.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @var array $_last_response_parsed
+	 */
+	protected $_last_response_parsed;
+
+	/**
+	 * An array key in the test exporter to unset.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @var string $key_to_unset
+	 */
+	protected $key_to_unset;
+
+	/**
+	 * A value to change the test exporter callback to.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @var string $new_callback_value
+	 */
+	protected $new_callback_value;
+
+	/**
+	 * Create user export request fixtures.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @param WP_UnitTest_Factory $factory Factory.
+	 */
+	public static function wpSetUpBeforeClass( $factory ) {
+		self::$request_email          = 'requester@example.com';
+		self::$request_id             = wp_create_user_request( self::$request_email, 'export_personal_data' );
+		self::$action                 = 'wp-privacy-export-personal-data';
+		self::$exporter               = 1;
+		self::$exporter_key           = 'custom-exporter';
+		self::$exporter_friendly_name = 'Custom Exporter';
+		self::$page                   = 1;
+		self::$send_as_email          = false;
+	}
+
+	/**
+	 * Setup before each test method.
+	 *
+	 * @since 4.9.9
+	 */
+	public function setUp() {
+		parent::setUp();
+
+		// Make sure the exporter response is not modified and avoid e.g. writing export file to disk.
+		remove_all_filters( 'wp_privacy_personal_data_export_page' );
+
+		// Only use our custom privacy personal data exporter.
+		remove_all_filters( 'wp_privacy_personal_data_exporters' );
+		add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'filter_register_custom_personal_data_exporter' ) );
+
+		$this->_setRole( 'administrator' );
+	}
+
+	/**
+	 * Clean up after each test method.
+	 */
+	public function tearDown() {
+		remove_filter( 'wp_privacy_personal_data_exporters', array( $this, 'filter_register_custom_personal_data_exporter' ) );
+		$this->key_to_unset       = '';
+		$this->new_callback_value = '';
+
+		parent::tearDown();
+	}
+
+	/**
+	 * Unset a specified key in the test exporter array.
+	 *
+	 * @param array $exporters List of data exporters.
+	 * @return array $exporters List of data exporters.
+	 */
+	public function filter_unset_exporter_value( $exporters ) {
+		if ( ! empty( $this->key_to_unset ) ) {
+			unset( $exporters[ self::$exporter_key ][ $this->key_to_unset ] );
+		}
+
+		return $exporters;
+	}
+
+	/**
+	 * Change the test exporter callback to a specified value.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @param array $exporters List of data exporters.
+	 * @return array $exporters List of data exporters.
+	 */
+	public function filter_exporter_callback_index_value( $exporters ) {
+		$exporters[ self::$exporter_key ]['callback'] = $this->new_callback_value;
+
+		return $exporters;
+	}
+
+	/**
+	 * The function should send an error when the request ID is missing.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_missing_request_id() {
+		$this->_make_ajax_call(
+			array(
+				'id' => null, // Missing request ID.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Missing request ID.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the request ID is less than 1.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_invalid_id() {
+		$this->_make_ajax_call(
+			array(
+				'id' => -1, // Invalid request ID, less than 1.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Invalid request ID.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the current user is missing the required capability.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_current_user_missing_required_capability() {
+		$this->_setRole( 'author' );
+
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertFalse( current_user_can( 'export_others_personal_data' ) );
+		$this->assertSame( 'Invalid request.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the nonce does not validate.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_failure_with_invalid_nonce() {
+		$this->setExpectedException( 'WPAjaxDieStopException', '-1' );
+
+		$this->_make_ajax_call(
+			array(
+				'security' => 'invalid-nonce',
+			)
+		);
+	}
+
+	/**
+	 * The function should send an error when the request type is incorrect.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_incorrect_request_type() {
+		$request_id = wp_create_user_request(
+			'erase-request@example.com',
+			'remove_personal_data' // Incorrect request type, expects 'export_personal_data'.
+		);
+
+		$this->_make_ajax_call(
+			array(
+				'security' => wp_create_nonce( 'wp-privacy-export-personal-data-' . $request_id ),
+				'id'       => $request_id,
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Invalid request type.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the requester's email address is invalid.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_invalid_email_address() {
+		wp_update_post(
+			array(
+				'ID'         => self::$request_id,
+				'post_title' => '', // Invalid requester's email address.
+			)
+		);
+
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'A valid email address must be given.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the exporter index is missing.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_missing_exporter_index() {
+		$this->_make_ajax_call(
+			array(
+				'exporter' => null, // Missing exporter index.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Missing exporter index.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the page index is missing.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_missing_page_index() {
+		$this->_make_ajax_call(
+			array(
+				'page' => null, // Missing page index.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Missing page index.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when an exporter has improperly used the `wp_privacy_personal_data_exporters` filter.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_exporter_has_improperly_used_exporters_filter() {
+		// Improper filter usage: returns false instead of an expected array.
+		add_filter( 'wp_privacy_personal_data_exporters', '__return_false', 999 );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'An exporter has improperly used the registration filter.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the exporter index is negative.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_negative_exporter_index() {
+		$this->_make_ajax_call(
+			array(
+				'exporter' => -1, // Negative exporter index.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Exporter index cannot be negative.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the exporter index is out of range.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_exporter_index_out_of_range() {
+		$this->_make_ajax_call(
+			array(
+				'exporter' => PHP_INT_MAX, // Out of range exporter index.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Exporter index is out of range.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the page index is less than one.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_page_index_less_than_one() {
+		$this->_make_ajax_call(
+			array(
+				'page' => 0, // Page index less than one.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Page index cannot be less than one.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when an exporter is not an array.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_exporter_not_array() {
+		add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'filter_exporter_not_array' ), 20 );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Expected an array describing the exporter at index %s.',
+				self::$exporter_key
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * Change the exporter information to an invalid value.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @param array $exporters Exporters.
+	 * @return array $exporters Exporters.
+	 */
+	public function filter_exporter_not_array( $exporters ) {
+		$exporters[ self::$exporter_key ] = false;
+
+		return $exporters;
+	}
+
+	/**
+	 * The function should send an error when an exporter is missing a friendly name.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_exporter_missing_friendly_name() {
+		$this->key_to_unset = 'exporter_friendly_name';
+
+		add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'filter_unset_exporter_value' ), 20 );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Exporter array at index %s does not include a friendly name.',
+				self::$exporter_key
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * The function should send an error when an exporter is missing a callback.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_exporter_missing_callback() {
+		$this->key_to_unset = 'callback';
+
+		add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'filter_unset_exporter_value' ), 20 );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Exporter does not include a callback: %s.',
+				self::$exporter_friendly_name
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * The function should send an error when an exporter, at a given index, has an invalid callback.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_exporter_index_invalid_callback() {
+		$this->new_callback_value = false;
+
+		add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'filter_exporter_callback_index_value' ), 20 );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Exporter callback is not a valid callback: %s.',
+				self::$exporter_friendly_name
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * When an exporter callback returns a WP_Error, it should be passed as the error.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_exporter_callback_returns_wp_error() {
+		$this->new_callback_value = array( $this, 'callback_return_wp_error' );
+
+		add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'filter_exporter_callback_index_value' ), 20 );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'passed_message', $this->_last_response_parsed['data'][0]['code'] );
+		$this->assertSame( 'This is a WP_Error message.', $this->_last_response_parsed['data'][0]['message'] );
+	}
+
+	/**
+	 * Callback for exporter's response.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @param string $email_address The requester's email address.
+	 * @param int    $page          Page number.
+	 * @return WP_Error WP_Error instance.
+	 */
+	public function callback_return_wp_error( $email_address, $page = 1 ) {
+		return new WP_Error( 'passed_message', 'This is a WP_Error message.' );
+	}
+
+	/**
+	 * The function should send an error when an exporter, at a given index, is missing an array response.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_exporter_index_invalid_response() {
+		$this->new_callback_value = '__return_null';
+
+		add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'filter_exporter_callback_index_value' ), 20 );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Expected response as an array from exporter: %s.',
+				self::$exporter_friendly_name
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * The function should send an error when an exporter is missing data in array response.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_exporter_missing_data_response() {
+		$this->new_callback_value = array( $this, 'callback_missing_data_response' );
+
+		add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'filter_exporter_callback_index_value' ), 20 );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Expected data in response array from exporter: %s.',
+				self::$exporter_friendly_name
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * Callback for exporter's response.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @param string $email_address The requester's email address.
+	 * @param int    $page          Page number.
+	 * @return array $return Export data.
+	 */
+	public function callback_missing_data_response( $email_address, $page = 1 ) {
+		$response = $this->callback_custom_personal_data_exporter( $email_address, $page );
+		unset( $response['data'] ); // Missing data part of response.
+
+		return $response;
+	}
+
+	/**
+	 * The function should send an error when an exporter is missing 'data' array in array response.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_function_should_error_when_exporter_missing_data_array_response() {
+		$this->new_callback_value = array( $this, 'callback_missing_data_array_response' );
+
+		add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'filter_exporter_callback_index_value' ), 20 );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Expected data array in response array from exporter: %s.',
+				self::$exporter_friendly_name
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * Callback for exporter's response.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @param  string $email_address The requester's email address.
+	 * @param  int    $page          Page number.
+	 *
+	 * @return array $return Export data.
+	 */
+	public function callback_missing_data_array_response( $email_address, $page = 1 ) {
+		$response         = $this->callback_custom_personal_data_exporter( $email_address, $page );
+		$response['data'] = false; // Not an array.
+		return $response;
+	}
+
+	/**
+	 * The function should send an error when an exporter is missing 'done' in array response.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_error_when_exporter_missing_done_response() {
+		$this->new_callback_value = array( $this, 'callback_missing_done_response' );
+
+		add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'filter_exporter_callback_index_value' ), 20 );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Expected done (boolean) in response array from exporter: %s.',
+				self::$exporter_friendly_name
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * Remove the response's done flag.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @param string $email_address The requester's email address.
+	 * @param int    $page          Page number.
+	 * @return array $return Export data.
+	 */
+	public function callback_missing_done_response( $email_address, $page = 1 ) {
+		$response = $this->callback_custom_personal_data_exporter( $email_address, $page );
+		unset( $response['done'] );
+
+		return $response;
+	}
+
+	/**
+	 * The function should successfully send exporter data response when the current user has the required capability.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_succeeds_when_current_user_has_required_capability() {
+		$this->_make_ajax_call();
+
+		$this->assertTrue( $this->_last_response_parsed['success'] );
+		$this->assertTrue( current_user_can( 'export_others_personal_data' ) );
+		$this->assertSame( 'custom-exporter-item-id', $this->_last_response_parsed['data']['data']['item_id'] );
+		$this->assertSame( 'Email', $this->_last_response_parsed['data']['data']['data'][0]['name'] );
+		$this->assertSame( self::$request_email, $this->_last_response_parsed['data']['data']['data'][0]['value'] );
+	}
+
+	/**
+	 * The function's output should be filterable with the `wp_privacy_personal_data_export_page` filter.
+	 *
+	 * @since 4.9.9
+	 */
+	public function test_output_should_be_filterable() {
+		add_filter( 'wp_privacy_personal_data_export_page', array( $this, 'filter_exporter_data_response' ), 20, 7 );
+		$this->_make_ajax_call();
+
+		$expected_group_label = sprintf(
+			'%s-%s-%s-%s-%s-%s',
+			self::$exporter,
+			self::$page,
+			self::$request_email,
+			self::$request_id,
+			self::$send_as_email,
+			self::$exporter_key
+		);
+
+		$this->assertTrue( $this->_last_response_parsed['success'] );
+		$this->assertSame( $expected_group_label, $this->_last_response_parsed['data']['group_label'] );
+		$this->assertSame( 'filtered_group_id', $this->_last_response_parsed['data']['group_id'] );
+		$this->assertSame( 'filtered_item_id', $this->_last_response_parsed['data']['item_id'] );
+		$this->assertSame( 'filtered_name', $this->_last_response_parsed['data']['data'][0]['name'] );
+		$this->assertSame( 'filtered_value', $this->_last_response_parsed['data']['data'][0]['value'] );
+	}
+
+	/**
+	 * Filter exporter's data response.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @param array  $response        The personal data for the given exporter and page.
+	 * @param int    $exporter_index  The index of the exporter that provided this data.
+	 * @param string $email_address   The email address associated with this personal data.
+	 * @param int    $page            The page for this response.
+	 * @param int    $request_id      The privacy request post ID associated with this request.
+	 * @param bool   $send_as_email   Whether the final results of the export should be emailed to the user.
+	 * @param string $exporter_key    The key (slug) of the exporter that provided this data.
+	 *
+	 * @return array $response The personal data for the given exporter and page.
+	 */
+	public function filter_exporter_data_response( $response, $exporter_index, $email_address, $page, $request_id, $send_as_email, $exporter_key ) {
+		$group_label                  = sprintf(
+			'%s-%s-%s-%s-%s-%s',
+			$exporter_index,
+			$page,
+			$email_address,
+			$request_id,
+			$send_as_email,
+			$exporter_key
+		);
+		$response['group_label']      = $group_label;
+		$response['group_id']         = 'filtered_group_id';
+		$response['item_id']          = 'filtered_item_id';
+		$response['data'][0]['name']  = 'filtered_name';
+		$response['data'][0]['value'] = 'filtered_value';
+
+		return $response;
+	}
+
+	/**
+	 * Filter to register a custom personal data exporter.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @param array $exporters An array of personal data exporters.
+	 * @return array $exporters An array of personal data exporters.
+	 */
+	public function filter_register_custom_personal_data_exporter( $exporters ) {
+		$exporters[ self::$exporter_key ] = array(
+			'exporter_friendly_name' => self::$exporter_friendly_name,
+			'callback'               => array( $this, 'callback_custom_personal_data_exporter' ),
+		);
+		return $exporters;
+	}
+
+	/**
+	 * Callback for a custom personal data exporter.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @param string $email_address The requester's email address.
+	 * @param int    $page          Page number.
+	 *
+	 * @return array $response Export data response.
+	 */
+	public function callback_custom_personal_data_exporter( $email_address, $page = 1 ) {
+		$data_to_export = array();
+
+		if ( 1 === $page ) {
+			$data_to_export = array(
+				'group_id'    => self::$exporter_key . '-group-id',
+				'group_label' => self::$exporter_key . '-group-label',
+				'item_id'     => self::$exporter_key . '-item-id',
+				'data'        => array(
+					array(
+						'name'  => 'Email',
+						'value' => $email_address,
+					),
+				),
+			);
+		}
+
+		return array(
+			'data' => $data_to_export,
+			'done' => true,
+		);
+	}
+
+	/**
+	 * Helper function for ajax handler.
+	 *
+	 * @since 4.9.9
+	 *
+	 * @param array $args Ajax request arguments.
+	 */
+	protected function _make_ajax_call( $args = array() ) {
+		$this->_last_response_parsed = null;
+		$this->_last_response        = '';
+
+		$defaults = array(
+			'action'      => self::$action,
+			'security'    => wp_create_nonce( self::$action . '-' . self::$request_id ),
+			'exporter'    => self::$exporter,
+			'page'        => self::$page,
+			'sendAsEmail' => self::$send_as_email,
+			'id'          => self::$request_id,
+		);
+
+		$_POST = wp_parse_args( $args, $defaults );
+
+		try {
+			$this->_handleAjax( self::$action );
+		} catch ( WPAjaxDieContinueException $e ) {
+			unset( $e );
+		}
+
+		if ( $this->_last_response ) {
+			$this->_last_response_parsed = json_decode( $this->_last_response, true );
+		}
+	}
+}
