3

I'm trying to test a PowerShell function that sets the bindings for an IIS website. For the tests I want to create a Site object, that represents an existing website, with a Bindings property.

I'm using the following code to create a Site object and its Bindings:

function RemoveMock ($BindingParameter) { }

$existingSiteBindingsArray = @(
        @{ Protocol = 'http'; BindingInformation = '*:4000:' }
        @{ Protocol = 'https'; BindingInformation = '*:5000:' }
    )

$bindingsObject = [PSCustomObject]$existingSiteBindingsArray

Add-Member -InputObject $bindingsObject -MemberType ScriptMethod -Name Remove `
    -Value { RemoveMock -BindingParameter $args[0] } -Force

$websiteProperties = @{ Name = 'MyWebsite'; Bindings = $bindingsObject }
$existingSiteObject = New-MockObject -Type 'Microsoft.Web.Administration.Site' `
    -Properties $websiteProperties

This seems to work. However, I'd like to avoid repeating all this code for every test. So I would like to modify the code to use a function to get the test Bindings:

function RemoveMock ($BindingParameter) { }

function GetBindingsObject([array]$BindingsArray)
{
    $bindingsObject = [PSCustomObject]$BindingsArray
    Add-Member -InputObject $bindingsObject -MemberType ScriptMethod -Name Remove `
        -Value { RemoveMock -BindingParameter $args[0] } -Force

    return $bindingsObject
}
    
$existingSiteBindingsArray = @(
        @{ Protocol = 'http'; BindingInformation = '*:4000:' }
        @{ Protocol = 'https'; BindingInformation = '*:5000:' }
    )

$bindingsObject = GetBindingsObject -BindingsArray $existingSiteBindingsArray

$websiteProperties = @{ Name = 'MyWebsite'; Bindings = $bindingsObject }
$existingSiteObject = New-MockObject -Type 'Microsoft.Web.Administration.Site' `
    -Properties $websiteProperties

This doesn't work because the $args[0] picks up the argument passed into the function, rather than the argument passed into the mocked Remove method.

Is there any way to specify the argument(s) passed into a script method added via Add-Member if Add-Member is called within a function?

(note that although the modified code is longer than the original, the function will be called repeatedly for multiple tests, and in reality the function will be longer, saving more lines per test)

EDIT: Replaced AddMock with RemoveMock for consistency. Added RemoveMock function definition.

2
  • 1
    Does remove have an argument ? Can you share the actual method signature ? You're definitely missing a param block in the method. You're also missing a loop on $BindingsArray, casting pscustomobject to an array doesnt do anything Commented May 3 at 11:28
  • Remove has an argument of type Binding: learn.microsoft.com/en-us/dotnet/api/…. I realised I forgot the mocked function definition in the code sample so I've edited the question to include it. Hopefully that makes more sense. Commented May 4 at 0:54

2 Answers 2

3
  • $args inside a script block serving as a ScriptMethod ETS member works as expected even from inside a function: it refers to whatever (unbound) arguments are passed by the caller on invocation.

    • As an aside: consider using a param(...) block to formally declare parameters for the expected arguments; e.g., Add-Member -MemberType ScriptMethod ... -Value { param($Binding) AddMock -BindingParameter $Binding } -Force. However, note that manual checks are required to ensure that all mandatory parameters receive arguments on invocation and that no extra, unexpected arguments are passed; e.g., if (-not $PSBoundParameter.ContainsKey('binding')) { throw "Missing -Binding argument" } and if ($args.Count) { throw "Unexpected argument(s) passed: $args" })[1]
  • One problem is the attempt to construct a [pscustomobject] from an array, as Santiago points out:

    • You can only meaningfully use a [pscustomobject] cast on a (single, potentially ordered) hashtable; with all other types, this cast is a virtual no-op (it creates a mostly invisible [psobject] wrapper).[2]

    • You don't actually need a [pscustomobject] instance in order to attach a ScriptMethod member to your array - just attach it to the array itself.

  • The bigger problem is that the ScriptMethod you added to the array is lost due to auto-enumeration when return $bindingsObject is executed (given that you're in effect outputting an array rather than a [pscustomobject]): it isn't the array itself that is output, but its elements, one by one, and $bindingsObject = ... collects them in a new array; you can use return , $bindingsObject to avoid that (or, more verbosely, but conceptually more clearly, Write-Output -NoEnumerate $bindingsObject) or use Add-Member -PassThru to directly output the decorated array as a whole to the pipeline, as shown below.[3]


Therefore:

function GetBindingsObject([array]$BindingsArray)
{
    # Decorate the array with a ScriptMethod and write it to the pipeline *as a whole*.
    Add-Member -PassThru -InputObject $BindingsArray -MemberType ScriptMethod -Name Remove `
        -Value { AddMock -BindingParameter $args[0] } -Force
}

$existingSiteBindingsArray = @(
        @{ Protocol = 'http'; BindingInformation = '*:4000:' }
        @{ Protocol = 'https'; BindingInformation = '*:5000:' }
    )

$bindingsObject = GetBindingsObject -BindingsArray $existingSiteBindingsArray

$websiteProperties = @{ Name = 'MyWebsite'; Bindings = $bindingsObject }
$existingSiteObject = New-MockObject -Type 'Microsoft.Web.Administration.Site' `
    -Properties $websiteProperties

[1] Note that the usual automatic techniques do not work in the case of a ScriptMethod ETS member: [Parameter(Mandatory)] attributes are ignored, and so is [CmdletBindingAttribute()]; similarly, the automatic enforcement of passing only arguments that bind to declared parameters doesn't work (use of either attribute normally makes a script block / function / script an advanced one, which normally implies this behavior).

[2] For more information on this problematic behavior, see this answer.

[3] See this answer for more information.

Sign up to request clarification or add additional context in comments.

3 Comments

I wasn't aware that arrays are auto-enuerated when returned from a function. I guess breaking an array apart and reassembling it has never been an issue before, so I never noticed what was actually happening. Thanks for that info.
Could you please clarify your comment about adding a param block? Do you mean something like Add-Member -MemberType ScriptMethod ... -Value { param($binding) AddMock -BindingParameter $binding } -Force?
Glad to hear it helped, @SimonElms. Yes, that is what I meant with respect to using a param(...) block - I've added this information to the answer, along with a note that even if you formally declare parameters, you still need a manual check to ensure that the caller doesn't pass extra, unexpected arguments.
1

Presumably you're after replicating BindingCollection.Remove Method in which case you don't really need Add-Member at all, you could convert your $existingSiteBindingsArray into a collection that supports removing, for example, a List<Binding> and add each hashtable to the list using New-MockObject with type as Binding. Since I don't have the Microsoft.Web.Administration, I'll be using this helper class for the demo, in reality you should be able to replicate the test by having the module loaded:

Add-Type '
namespace Microsoft.Web.Administration
{
    public class Binding
    {
        public string Protocol { get; set; }
        public string BindingInformation { get; set; }
    }
}'

Then, for the function, takes an array and creates a List<Binding> then for each element, creates a new mock object of type Binding and adds it to the collection, lastly outputs the list:

function GetBindingsObject([array] $BindingsArray) {
    $mockCollection = [System.Collections.Generic.List[Microsoft.Web.Administration.Binding]]::new()
    $BindingsArray | ForEach-Object {
        $obj = New-MockObject -Type Microsoft.Web.Administration.Binding -Properties $_
        $mockCollection.Add($obj)
    }

    , $mockCollection
}

And for the .Remove(Binding element) test:

$existingSiteBindingsArray = @(
    @{ Protocol = 'http'; BindingInformation = '*:4000:' }
    @{ Protocol = 'https'; BindingInformation = '*:5000:' }
)

$bindingsObject = GetBindingsObject -BindingsArray $existingSiteBindingsArray
$bindingsObject.GetType()

#    Namespace: System.Collections.Generic
#
# Access        Modifiers           Name
# ------        ---------           ----
# public        class               List<Binding> : object, IList<Binding>, ...


$bindingsObject[0].GetType()

#    Namespace: Microsoft.Web.Administration
#
# Access        Modifiers           Name
# ------        ---------           ----
# public        class               Binding : object

$bindingsObject.Remove($bindingsObject[0])
$bindingsObject # Should have 1 element

2 Comments

Hadn't thought of using List<Binding> as a way around the problem. Elegant solution. Thanks. However, I think mklement0's answer it more general so I'll accept that.
@SimonElms I think using Add-Member is more complicated than it should be 🤷‍♂️

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.