Scenarios
1.3 Scenarios
The following scenarios will now be described.
Environment variables
Environment variables are initialised in a separate file. It is not good practice to do this in a function block.
import os
from dotenv import load_dotenv
load_dotenv()
LOCALHOST = os.getenv("LOCALHOST")
PORT = os.getenv("PORT")
USERNAME = os.getenv("USERNAME")
PASSWORD = os.getenv("PASSWORD")
@Patch and Mypy
Because @patch does not retrieve the type of the class or method it is mocking, mypy will always give an Any type error. Therefore, it temporarily ignores that error using: # type: ignore
Function block
Given the class and their correct types.
class FunctionBlock(Generic[ParamsT]):
@final
def __init__(self) -> None:
self.access_token: str = ""
self.client: Optional[Client] = self.configure_client()
self.params: Optional[ParamsT] = None
def configure_client(self) -> Client:
headers: Dict[str, str] = {"Content-Type": "application/json", "Accept": "application/json"}
if self.access_token:
headers["Authorization"] = f"Bearer {self.access_token}"
return Client(url=f"{Config.url_graphql}", headers=headers)
def save_token(self, token: str) -> None:
self.access_token = token
self.client = self.configure_client()
@abstractmethod
async def run(self, context: WorkflowContext, client: Client) -> FunctionBlockResult:
pass
@final
async def execute_function_block(self, context: WorkflowContext) -> FunctionBlockResult:
try:
if self.client is not None:
return await self.run(context, self.client)
return FunctionBlockResult("Function block could not be executed, check configuration")
except Exception as e:
return FunctionBlockResult(f"Error executing function block: {str(e)}")
To test FunctionBlock, It is important to create a subclass that inherits from it because it is an abstract class.
class FunctionBlockSubclass(FunctionBlock):
async def run(self, context: WorkflowContext) -> FunctionBlockResult:
return FunctionBlockResult(message="Test run successful.")
Then the test cases have to be prepared:
- Client initialisation: In this case MagicMock is used to simulate the Client.
@patch("neops_worker_sdk.function_block.function_block.FunctionBlock.configure_client") # type: ignore
def test_client_initialization(self, mock_configure_client: MagicMock) -> None:
# Mock the configure_client to return a mock client
mock_client = MagicMock()
mock_configure_client.return_value = mock_client
function_block = FunctionBlockSubclass()
# Assert the client was set correctly
self.assertEqual(function_block.client, mock_client)
- The run abstract method:
@patch("neops_worker_sdk.function_block.function_block.FunctionBlock.configure_client") # type: ignore
async def test_run_method(self, mock_configure_client: MagicMock) -> None:
# Mock the configure_client to return a mock client
mock_client: MagicMock = MagicMock()
mock_configure_client.return_value = mock_client
# Instantiate WorkflowContext and FunctionBlock subclass
context = WorkflowContext()
function_block = FunctionBlockSubclass()
# Call the run method
result = await function_block.run(context)
# Assert the result message
self.assertEqual(result.message, "Test run successful.")
- The save token method:
- By using side_effect, the scenario simulated is where the function block first initilizes with a Client then reconfigure the client after save the token.
- It is important to cast the type for the side_effect.
@patch("neops_worker_sdk.function_block.function_block.FunctionBlock.configure_client") # type: ignore
def test_save_token(self, mock_configure_client: MagicMock) -> None:
# Mock the configure_client to return a new client after token is set
mock_client_initial = MagicMock()
mock_client_after_token = MagicMock()
# First return the initial client, then the reconfigured client after token save
mock_configure_client.side_effect = cast(List[MagicMock], (mock_client_initial, mock_client_after_token))
function_block = FunctionBlockSubclass()
# Ensure the initial client is set
self.assertEqual(function_block.client, mock_client_initial)
# Call save_token and check if client is reconfigured
function_block.save_token("mock_access_token")
# Assert the access token is updated
self.assertEqual(function_block.access_token, "mock_access_token")
# Assert the client is reconfigured after saving the token
self.assertEqual(function_block.client, mock_client_after_token)
Login FB
Given the class:
@register_function_block(
Registration(
name="ping",
description="this fetches authenticated user data",
package="fb.core.neops.io",
run_on="",
fb_type="configure",
json_schema="",
)
)
class Login(FunctionBlock):
async def run(self, context: WorkflowContext) -> FunctionBlockResult:
try:
if self.client is None:
raise ValueError("Client is not configured correctly.")
# Perform login
response = await self.client.login(
username=USERNAME,
password=PASSWORD,
)
# Ensure response and login attributes are present
if response is None or response.login is None:
raise ValueError("Login failed, no response or login data received.")
access_token: Optional[str] = response.login.access_token
if access_token is None:
raise ValueError("Login failed, no access token received.")
self.save_token(access_token)
return FunctionBlockResult(message="Login successful.")
except Exception as e:
return FunctionBlockResult(message=f"Error fetching task data: {str(e)}")
Note: Always look at the structure of the code and check whether the piece of code you want to test uses internal or external calls.
- In this case, the login function use the external call to the Client (response.login.access_token). Then this should be mocked, but you need to understand what the real method is supposed to return: object? dictrionary? It contains nested fields?
- The mock should reflect the same structure as the real return value. If the real method return an object with attributes, the mock sohuld return and object with similar attributes. Because this ensure that test are realistic and that they won't break when runing.
- Instead of just testing weather a certain value is returned, the focus should be on testing how the code interacts with the result.
- If the real method might throw an exception or behave differently in some cases, then side_effect shold be used.
Login succes: In this case the mocking is done to the acces_token
@patch("neops_worker_sdk.function_block.function_block.FunctionBlock.configure_client") # type: ignore
async def test_login_success(self, mock_configure_client: AsyncMock) -> None:
# Mock the client and its login method to return a valid access_token
mock_client: AsyncMock = AsyncMock()
mock_client.login = AsyncMock(return_value=AsyncMock(login=AsyncMock(access_token="mock_access_token")))
mock_configure_client.return_value = mock_client
# Instantiate WorkflowContext and Login block
context: WorkflowContext = WorkflowContext()
login_block: FunctionBlock = Login()
# Run the login block
result: FunctionBlockResult = await login_block.run(context)
# Assert that login was successful
self.assertEqual(result.message, "Login successful.")
# Assert that the token is saved correctly
self.assertEqual(login_block.access_token, "mock_access_token")
Login failure: In this case the side_effect is used to simulted the exception.
@patch("neops_worker_sdk.function_block.function_block.FunctionBlock.configure_client") # type: ignore
async def test_login_failure(self, mock_configure_client: AsyncMock) -> None:
# Mock the client and its login method to raise an exception
mock_client: AsyncMock = AsyncMock()
mock_client.login = AsyncMock(side_effect=Exception("Login failed"))
mock_configure_client.return_value = mock_client
# Instantiate WorkflowContext and Login block
context: WorkflowContext = WorkflowContext()
login_block: FunctionBlock = Login()
# Run the login block
result: FunctionBlockResult = await login_block.run(context)
# Assert that the error message is returned
self.assertEqual(result.message, "Error fetching task data: Login failed")
Get Device FB
Given the class:
@register_function_block(
Registration(
name="get_random_device",
description="This fetches a random device from the list of available devices",
package="fb.core.neops.io",
run_on="",
fb_type="query",
json_schema="",
)
)
class GetRandomDevice(FunctionBlock):
async def run(self, context: WorkflowContext) -> FunctionBlockResult:
try:
if self.client is None:
raise ValueError("Client is not configured correctly.")
# Fetch devices using the client method
response: Devices = await self.client.devices(
query="", # Optional query string can be empty to get all devices
page=1, # Fetch the first page
page_size=1,
)
# Check if devices_elastic or results are None
devices_elastic = response.devices_elastic
if devices_elastic is None or not devices_elastic.results:
return FunctionBlockResult(message="No devices found.")
# Device details
device: Optional[DevicesDevicesElasticResults] = devices_elastic.results[0]
if device is not None:
# Ensure connection_state and other attributes are not None
connection_state: str = device.connection_state.name if device.connection_state else "Unknown"
message = (
f"Random device fetched:\n"
f" Hostname: {device.hostname}\n"
f" IP: {device.ip}\n"
f" Connection State: {connection_state}\n"
)
return FunctionBlockResult(message=message)
else:
return FunctionBlockResult(message="No devices found.")
except Exception as e:
return FunctionBlockResult(message=f"Error fetching random device: {str(e)}")
To avoid problems with mypy. Note the types used in the attributes returned by the self.client.devices() method.
In this scenario, do the unit tests for these cases: When a device is found, when it is not found and when there is a client error.
Note: When writing unit tests that involve objects with attributes, such as a device, it is crucial to create the expected object using the relevant attributes rather than relying solely on mocks. This ensures that the test is more accurate and reflects the actual structure and behavior of the object being tested.
Test random device found
- DevicesDevicesElasticResults is a clear example of the importance of ensuring that the test behaves in a predictable way, even if the device changes in the future.
- mock_client.devices = AsyncMock(...) The divice method of mock_client is being mocked using AsyncMock. This means that whenever devices() is called in the code, it will return a mocked asynchronous result instead of executing the actual method.
- return_value=MagicMock(...) This indicates that when devices() is called, it will return a MagicMock object. The MagicMock represents the full result object returned by the devices() method.
- devices_elastic=MagicMock(...) Inside the mocked result of devices(), the device_elastic is the mocked attribute.
- results=cast(List[DevicesDevicesElasticResults], [device])} The cast function is used here to ensure that results is explicitly treated as a list of DevicesDevicesElasticResults objects, which helps with type checking.
@patch("neops_worker_sdk.function_block.function_block.FunctionBlock.configure_client") # type: ignore
async def test_random_device_found(self, mock_configure_client: AsyncMock) -> None:
# Mock the client
mock_client = AsyncMock()
# Create an DevicesDevicesElasticResults object
device: DevicesDevicesElasticResults = DevicesDevicesElasticResults(
id="device1",
createdAt=None,
updatedAt=None,
hostname="device1",
ip="192.168.1.1",
connectionState=ConnectionStateEnum.NEW,
connectionOptions=None,
facts=None,
softwareRelease=None,
platform=None,
vendor=None,
model=None,
serial=None,
username=None,
currentConfiguration=None,
groups=[],
interfaces=[],
permission=None,
checks=None,
checkElements=None,
)
# Mocking the device object in the DevicesDevicesElastic
mock_client.devices = AsyncMock(
return_value=MagicMock(
devices_elastic=MagicMock(
results=cast(List[DevicesDevicesElasticResults], [device]) # Explicitly cast the list
)
)
)
mock_configure_client.return_value = mock_client
# Instantiate WorkflowContext and GetRandomDevice block
context = WorkflowContext()
function_block = GetRandomDevice()
# Call the run method
result: FunctionBlockResult = await function_block.run(context)
# Assert the result message
expected_message = (
"Random device fetched:\n" " Hostname: device1\n" " IP: 192.168.1.1\n" " Connection State: NEW\n"
)
self.assertEqual(result.message, expected_message)
Test no device found
Here, an empty list of the type is returned: List[DevicesDevicesElasticResults]
@patch("neops_worker_sdk.function_block.function_block.FunctionBlock.configure_client") # type: ignore
async def test_no_device_found(self, mock_configure_client: AsyncMock) -> None:
# Mock the client and the devices method to return an empty list
mock_client = AsyncMock()
mock_client.devices = AsyncMock(
return_value=MagicMock(devices_elastic=MagicMock(results=cast(List[DevicesDevicesElasticResults], [])))
)
mock_configure_client.return_value = mock_client
# Instantiate WorkflowContext and GetRandomDevice block
context = WorkflowContext()
function_block = GetRandomDevice()
# Call the run method
result: FunctionBlockResult = await function_block.run(context)
# Assert the result message for no device found
self.assertEqual(result.message, "No devices found.")
Test client error
@patch("neops_worker_sdk.function_block.function_block.FunctionBlock.configure_client") # type: ignore
async def test_client_error(self, mock_configure_client: AsyncMock) -> None:
# Mock the client and the devices method to raise an exception
mock_client = AsyncMock()
mock_client.devices = AsyncMock(side_effect=Exception("Client error"))
mock_configure_client.return_value = mock_client
# Instantiate WorkflowContext and GetRandomDevice block
context = WorkflowContext()
function_block = GetRandomDevice()
# Call the run method and expect it to handle the exception
result: FunctionBlockResult = await function_block.run(context)
# Assert the error message is returned
self.assertEqual(result.message, "Error fetching random device: Client error")